Merge branch 'just-experimenting' into test-abel
This commit is contained in:
@@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration
|
||||
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
||||
public bool ShowMessageTimestamps { get; set; } = true;
|
||||
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
||||
public bool EnableAnimatedEmotes { get; set; } = true;
|
||||
public float ChatWindowOpacity { get; set; } = .97f;
|
||||
public bool FadeWhenUnfocused { get; set; } = false;
|
||||
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
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)
|
||||
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_uiSharedService = uiSharedService;
|
||||
_chatConfigService = chatConfigService;
|
||||
}
|
||||
|
||||
public void EnsureGlobalEmotesLoaded()
|
||||
@@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.Texture is not null)
|
||||
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
|
||||
if (entry.TryGetTexture(allowAnimation, out texture))
|
||||
{
|
||||
texture = entry.Texture;
|
||||
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
|
||||
{
|
||||
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
entry.EnsureLoading(QueueEmoteDownload);
|
||||
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
foreach (var entry in _emotes.Values)
|
||||
{
|
||||
entry.Texture?.Dispose();
|
||||
entry.Dispose();
|
||||
}
|
||||
|
||||
_downloadGate.Dispose();
|
||||
@@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = TryBuildEmoteUrl(emoteElement);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
var source = TryBuildEmoteSource(emoteElement);
|
||||
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_emotes.TryAdd(name, new EmoteEntry(url));
|
||||
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
|
||||
private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement)
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||
{
|
||||
@@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = PickBestStaticFile(filesElement);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
var files = ReadEmoteFiles(filesElement);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return baseUrl.TrimEnd('/') + "/" + fileName;
|
||||
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? PickBestStaticFile(JsonElement filesElement)
|
||||
{
|
||||
string? png1x = null;
|
||||
string? webp1x = null;
|
||||
string? pngFallback = null;
|
||||
string? webpFallback = null;
|
||||
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("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
@@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable
|
||||
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;
|
||||
@@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
webp1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
gif1x = name;
|
||||
}
|
||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||
{
|
||||
pngFallback = name;
|
||||
@@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
webpFallback = name;
|
||||
}
|
||||
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
||||
{
|
||||
gifFallback = name;
|
||||
}
|
||||
}
|
||||
|
||||
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
|
||||
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
|
||||
}
|
||||
|
||||
private void QueueEmoteDownload(EmoteEntry entry)
|
||||
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
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
||||
var texture = _uiSharedService.LoadImage(data);
|
||||
entry.SetTexture(texture);
|
||||
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 {Url}", entry.Url);
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
|
||||
entry.MarkFailed();
|
||||
}
|
||||
finally
|
||||
@@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EmoteEntry
|
||||
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
|
||||
{
|
||||
private int _loadingState;
|
||||
|
||||
public EmoteEntry(string url)
|
||||
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
|
||||
{
|
||||
Url = url;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string Url { get; }
|
||||
public IDalamudTextureWrap? Texture { get; private set; }
|
||||
|
||||
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
||||
try
|
||||
{
|
||||
if (Texture is not null)
|
||||
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;
|
||||
}
|
||||
@@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
queueDownload(this);
|
||||
queueDownload(this, allowAnimation);
|
||||
}
|
||||
|
||||
public void SetTexture(IDalamudTextureWrap texture)
|
||||
public void SetAnimation(EmoteAnimation animation)
|
||||
{
|
||||
Texture = texture;
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_animation?.Dispose();
|
||||
_staticTexture?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var address in _actorTracker.PlayerAddresses)
|
||||
foreach (var descriptor in _actorTracker.PlayerDescriptors)
|
||||
{
|
||||
if (address == nint.Zero)
|
||||
if (string.IsNullOrEmpty(descriptor.HashedContentId))
|
||||
continue;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
|
||||
var cid = descriptor.HashedContentId;
|
||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||
|
||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
|
||||
|
||||
@@ -22,7 +22,6 @@ using LightlessSync.UI.Services;
|
||||
using LightlessSync.UI.Style;
|
||||
using LightlessSync.Utils;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using OtterGui.Text;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -429,150 +428,182 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
else
|
||||
{
|
||||
var itemHeight = ImGui.GetTextLineHeightWithSpacing();
|
||||
using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight);
|
||||
while (clipper.Step())
|
||||
var messageCount = channel.Messages.Count;
|
||||
var contentMaxX = ImGui.GetWindowContentRegionMax().X;
|
||||
var cursorStartX = ImGui.GetCursorPosX();
|
||||
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
|
||||
var prefix = new float[messageCount + 1];
|
||||
var totalHeight = 0f;
|
||||
|
||||
for (var i = 0; i < messageCount; i++)
|
||||
{
|
||||
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
|
||||
var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot);
|
||||
if (messageHeight <= 0f)
|
||||
{
|
||||
var message = channel.Messages[i];
|
||||
ImGui.PushID(i);
|
||||
messageHeight = lineHeightWithSpacing;
|
||||
}
|
||||
|
||||
if (message.IsSystem)
|
||||
totalHeight += messageHeight;
|
||||
prefix[i + 1] = totalHeight;
|
||||
}
|
||||
|
||||
var scrollY = ImGui.GetScrollY();
|
||||
var windowHeight = ImGui.GetWindowHeight();
|
||||
var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1);
|
||||
var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight));
|
||||
startIndex = Math.Max(0, startIndex - 2);
|
||||
endIndex = Math.Min(messageCount, endIndex + 2);
|
||||
|
||||
if (startIndex > 0)
|
||||
{
|
||||
ImGui.Dummy(new Vector2(1f, prefix[startIndex]));
|
||||
}
|
||||
|
||||
for (var i = startIndex; i < endIndex; i++)
|
||||
{
|
||||
var message = channel.Messages[i];
|
||||
ImGui.PushID(i);
|
||||
|
||||
if (message.IsSystem)
|
||||
{
|
||||
DrawSystemEntry(message);
|
||||
ImGui.PopID();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.Payload is not { } payload)
|
||||
{
|
||||
ImGui.PopID();
|
||||
continue;
|
||||
}
|
||||
|
||||
var timestampText = string.Empty;
|
||||
if (showTimestamps)
|
||||
{
|
||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||
}
|
||||
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
||||
var showRoleIcons = false;
|
||||
var isOwner = false;
|
||||
var isModerator = false;
|
||||
var isPinned = false;
|
||||
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||
var groupId = channel.Descriptor.CustomKey;
|
||||
if (!string.IsNullOrWhiteSpace(groupId)
|
||||
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||
{
|
||||
DrawSystemEntry(message);
|
||||
ImGui.PopID();
|
||||
continue;
|
||||
var senderUid = payload.Sender.User.UID;
|
||||
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||
{
|
||||
isModerator = info.IsModerator();
|
||||
isPinned = info.IsPinned();
|
||||
}
|
||||
}
|
||||
|
||||
if (message.Payload is not { } payload)
|
||||
showRoleIcons = isOwner || isModerator || isPinned;
|
||||
}
|
||||
|
||||
ImGui.BeginGroup();
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, color);
|
||||
if (showRoleIcons)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(timestampText))
|
||||
{
|
||||
ImGui.PopID();
|
||||
continue;
|
||||
ImGui.TextUnformatted(timestampText);
|
||||
ImGui.SameLine(0f, 0f);
|
||||
}
|
||||
|
||||
var timestampText = string.Empty;
|
||||
if (showTimestamps)
|
||||
var hasIcon = false;
|
||||
if (isModerator)
|
||||
{
|
||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||
}
|
||||
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
||||
var showRoleIcons = false;
|
||||
var isOwner = false;
|
||||
var isModerator = false;
|
||||
var isPinned = false;
|
||||
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||
var groupId = channel.Descriptor.CustomKey;
|
||||
if (!string.IsNullOrWhiteSpace(groupId)
|
||||
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||
{
|
||||
var senderUid = payload.Sender.User.UID;
|
||||
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||
{
|
||||
isModerator = info.IsModerator();
|
||||
isPinned = info.IsPinned();
|
||||
}
|
||||
}
|
||||
|
||||
showRoleIcons = isOwner || isModerator || isPinned;
|
||||
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
|
||||
UiSharedService.AttachToolTip("Moderator");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
ImGui.BeginGroup();
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, color);
|
||||
if (showRoleIcons)
|
||||
if (isOwner)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(timestampText))
|
||||
{
|
||||
ImGui.TextUnformatted(timestampText);
|
||||
ImGui.SameLine(0f, 0f);
|
||||
}
|
||||
|
||||
var hasIcon = false;
|
||||
if (isModerator)
|
||||
{
|
||||
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
|
||||
UiSharedService.AttachToolTip("Moderator");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isOwner)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
|
||||
UiSharedService.AttachToolTip("Owner");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isPinned)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
|
||||
UiSharedService.AttachToolTip("Pinned");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
|
||||
UiSharedService.AttachToolTip("Owner");
|
||||
hasIcon = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
}
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.EndGroup();
|
||||
|
||||
ImGui.SetNextWindowSizeConstraints(
|
||||
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
|
||||
new Vector2(float.MaxValue, float.MaxValue));
|
||||
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
||||
if (isPinned)
|
||||
{
|
||||
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
||||
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
||||
ImGui.TextDisabled(contextTimestampText);
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
if (hasIcon)
|
||||
{
|
||||
var aliasOrUid = payload.Sender.User.AliasOrUID;
|
||||
if (!string.IsNullOrWhiteSpace(aliasOrUid)
|
||||
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
|
||||
{
|
||||
ImGui.TextDisabled(aliasOrUid);
|
||||
}
|
||||
}
|
||||
ImGui.Separator();
|
||||
|
||||
var actionIndex = 0;
|
||||
foreach (var action in GetContextMenuActions(channel, message))
|
||||
{
|
||||
DrawContextMenuAction(action, actionIndex++);
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
ImGui.EndPopup();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
|
||||
UiSharedService.AttachToolTip("Pinned");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
ImGui.PopID();
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
}
|
||||
else
|
||||
{
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
}
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.EndGroup();
|
||||
|
||||
ImGui.SetNextWindowSizeConstraints(
|
||||
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
|
||||
new Vector2(float.MaxValue, float.MaxValue));
|
||||
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
||||
{
|
||||
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
||||
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
||||
ImGui.TextDisabled(contextTimestampText);
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
var aliasOrUid = payload.Sender.User.AliasOrUID;
|
||||
if (!string.IsNullOrWhiteSpace(aliasOrUid)
|
||||
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
|
||||
{
|
||||
ImGui.TextDisabled(aliasOrUid);
|
||||
}
|
||||
}
|
||||
ImGui.Separator();
|
||||
|
||||
var actionIndex = 0;
|
||||
foreach (var action in GetContextMenuActions(channel, message))
|
||||
{
|
||||
DrawContextMenuAction(action, actionIndex++);
|
||||
}
|
||||
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
|
||||
ImGui.PopID();
|
||||
}
|
||||
|
||||
var remainingHeight = totalHeight - prefix[endIndex];
|
||||
if (remainingHeight > 0f)
|
||||
{
|
||||
ImGui.Dummy(new Vector2(1f, remainingHeight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,7 +739,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
var clicked = false;
|
||||
if (texture is not null)
|
||||
{
|
||||
clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize));
|
||||
var buttonSize = new Vector2(itemWidth, itemHeight);
|
||||
clicked = ImGui.InvisibleButton("##emote_button", buttonSize);
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var itemMin = ImGui.GetItemRectMin();
|
||||
var itemMax = ImGui.GetItemRectMax();
|
||||
var bgColor = ImGui.IsItemActive()
|
||||
? ImGui.GetColorU32(ImGuiCol.ButtonActive)
|
||||
: ImGui.IsItemHovered()
|
||||
? ImGui.GetColorU32(ImGuiCol.ButtonHovered)
|
||||
: ImGui.GetColorU32(ImGuiCol.Button);
|
||||
drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding);
|
||||
var imageMin = itemMin + style.FramePadding;
|
||||
var imageMax = imageMin + new Vector2(emoteSize);
|
||||
drawList.AddImage(texture.Handle, imageMin, imageMax);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -878,7 +922,232 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
|
||||
private static bool IsEmoteChar(char value)
|
||||
{
|
||||
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!';
|
||||
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')';
|
||||
}
|
||||
|
||||
private float MeasureMessageHeight(
|
||||
ChatChannelSnapshot channel,
|
||||
ChatMessageEntry message,
|
||||
bool showTimestamps,
|
||||
float cursorStartX,
|
||||
float contentMaxX,
|
||||
float itemSpacing,
|
||||
ref PairUiSnapshot? pairSnapshot)
|
||||
{
|
||||
if (message.IsSystem)
|
||||
{
|
||||
return MeasureSystemEntryHeight(message);
|
||||
}
|
||||
|
||||
if (message.Payload is not { } payload)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var timestampText = string.Empty;
|
||||
if (showTimestamps)
|
||||
{
|
||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||
}
|
||||
|
||||
var showRoleIcons = false;
|
||||
var isOwner = false;
|
||||
var isModerator = false;
|
||||
var isPinned = false;
|
||||
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||
var groupId = channel.Descriptor.CustomKey;
|
||||
if (!string.IsNullOrWhiteSpace(groupId)
|
||||
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||
{
|
||||
var senderUid = payload.Sender.User.UID;
|
||||
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||
{
|
||||
isModerator = info.IsModerator();
|
||||
isPinned = info.IsPinned();
|
||||
}
|
||||
}
|
||||
|
||||
showRoleIcons = isOwner || isModerator || isPinned;
|
||||
}
|
||||
|
||||
var lineStartX = cursorStartX;
|
||||
string prefix;
|
||||
if (showRoleIcons)
|
||||
{
|
||||
lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing);
|
||||
prefix = $"{message.DisplayName}: ";
|
||||
}
|
||||
else
|
||||
{
|
||||
prefix = $"{timestampText}{message.DisplayName}: ";
|
||||
}
|
||||
|
||||
var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX);
|
||||
return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing();
|
||||
}
|
||||
|
||||
private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX)
|
||||
{
|
||||
var segments = BuildChatSegments(prefix, message);
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var emoteWidth = ImGui.GetTextLineHeight();
|
||||
var availableWidth = Math.Max(1f, contentMaxX - lineStartX);
|
||||
var remainingWidth = availableWidth;
|
||||
var firstOnLine = true;
|
||||
var lines = 1;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.IsLineBreak)
|
||||
{
|
||||
lines++;
|
||||
firstOnLine = true;
|
||||
remainingWidth = availableWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.IsWhitespace && firstOnLine)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X;
|
||||
if (!firstOnLine)
|
||||
{
|
||||
if (segmentWidth > remainingWidth)
|
||||
{
|
||||
lines++;
|
||||
firstOnLine = true;
|
||||
remainingWidth = availableWidth;
|
||||
if (segment.IsWhitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remainingWidth -= segmentWidth;
|
||||
firstOnLine = false;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing)
|
||||
{
|
||||
var width = 0f;
|
||||
|
||||
if (!string.IsNullOrEmpty(timestampText))
|
||||
{
|
||||
width += ImGui.CalcTextSize(timestampText).X;
|
||||
}
|
||||
|
||||
var hasIcon = false;
|
||||
if (isModerator)
|
||||
{
|
||||
width += MeasureIconWidth(FontAwesomeIcon.UserShield);
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isOwner)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
width += itemSpacing;
|
||||
}
|
||||
|
||||
width += MeasureIconWidth(FontAwesomeIcon.Crown);
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isPinned)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
width += itemSpacing;
|
||||
}
|
||||
|
||||
width += MeasureIconWidth(FontAwesomeIcon.Thumbtack);
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (hasIcon)
|
||||
{
|
||||
width += itemSpacing;
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
private float MeasureIconWidth(FontAwesomeIcon icon)
|
||||
{
|
||||
using var font = _uiSharedService.IconFont.Push();
|
||||
return ImGui.CalcTextSize(icon.ToIconString()).X;
|
||||
}
|
||||
|
||||
private float MeasureSystemEntryHeight(ChatMessageEntry entry)
|
||||
{
|
||||
_ = entry;
|
||||
var spacing = ImGui.GetStyle().ItemSpacing.Y;
|
||||
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
|
||||
var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale);
|
||||
|
||||
var height = spacing;
|
||||
height += lineHeightWithSpacing;
|
||||
height += spacing * 0.35f;
|
||||
height += separatorHeight;
|
||||
height += spacing;
|
||||
return height;
|
||||
}
|
||||
|
||||
private static int LowerBound(float[] values, float target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = (low + high) / 2;
|
||||
if (values[mid] < target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private static int UpperBound(float[] values, float target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = (low + high) / 2;
|
||||
if (values[mid] <= target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture)
|
||||
@@ -2092,6 +2361,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
|
||||
}
|
||||
|
||||
var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes;
|
||||
if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes))
|
||||
{
|
||||
chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes;
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("When disabled, emotes render as static images.");
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted("Chat Visibility");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user