using Dalamud.Game.Gui.Dtr; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; using System.Text; using LightlessSync.UI.Services; using static LightlessSync.Services.PairRequestService; using LightlessSync.Services.LightFinder; namespace LightlessSync.UI; public sealed class DtrEntry : IDisposable, IHostedService { private static readonly TimeSpan _localHashedCidCacheDuration = TimeSpan.FromMinutes(2); private static readonly TimeSpan _localHashedCidErrorCooldown = TimeSpan.FromMinutes(1); private readonly ApiController _apiController; private readonly ServerConfigurationManager _serverManager; private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly ConfigurationServiceBase _configService; private readonly IDtrBar _dtrBar; private readonly Lazy _statusEntry; private readonly Lazy _lightfinderEntry; private readonly ILogger _logger; private readonly LightFinderService _broadcastService; private readonly LightFinderScannerService _broadcastScannerService; private readonly LightlessMediator _lightlessMediator; private readonly PairUiService _pairUiService; private readonly PairRequestService _pairRequestService; private readonly DalamudUtilService _dalamudUtilService; private Task? _runTask; private string? _statusText; private string? _statusTooltip; private Colors _statusColors; private string? _lightfinderText; private string? _lightfinderTooltip; private Colors _lightfinderColors; private readonly object _localHashedCidLock = new(); private string? _localHashedCid; private DateTime _localHashedCidFetchedAt = DateTime.MinValue; private DateTime _localHashedCidNextErrorLog = DateTime.MinValue; private DateTime _pairRequestNextErrorLog = DateTime.MinValue; private int _localHashedCidRefreshActive; public DtrEntry( ILogger logger, IDtrBar dtrBar, ConfigurationServiceBase configService, LightlessMediator lightlessMediator, PairUiService pairUiService, PairRequestService pairRequestService, ApiController apiController, ServerConfigurationManager serverManager, LightFinderService broadcastService, LightFinderScannerService broadcastScannerService, DalamudUtilService dalamudUtilService) { _logger = logger; _dtrBar = dtrBar; _statusEntry = new(CreateStatusEntry); _lightfinderEntry = new(CreateLightfinderEntry); _configService = configService; _lightlessMediator = lightlessMediator; _pairUiService = pairUiService; _pairRequestService = pairRequestService; _apiController = apiController; _serverManager = serverManager; _broadcastService = broadcastService; _broadcastScannerService = broadcastScannerService; _dalamudUtilService = dalamudUtilService; } public void Dispose() { if (_statusEntry.IsValueCreated) { _logger.LogDebug("Disposing DtrEntry"); Clear(); _statusEntry.Value.Remove(); } if (_lightfinderEntry.IsValueCreated) _lightfinderEntry.Value.Remove(); } public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting DtrEntry"); _runTask = Task.Run(RunAsync, _cancellationTokenSource.Token); _logger.LogInformation("Started DtrEntry"); return Task.CompletedTask; } public async Task StopAsync(CancellationToken cancellationToken) { await _cancellationTokenSource.CancelAsync().ConfigureAwait(false); try { await _runTask!.ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogInformation("Lightfinder operation was canceled."); } finally { _cancellationTokenSource.Dispose(); } } private void Clear() { HideStatusEntry(); HideLightfinderEntry(); } private void HideStatusEntry() { if (_statusEntry.IsValueCreated && _statusEntry.Value.Shown) { _logger.LogInformation("Hiding status entry"); _statusEntry.Value.Shown = false; } _statusText = null; _statusTooltip = null; _statusColors = default; } private void HideLightfinderEntry() { if (_lightfinderEntry.IsValueCreated && _lightfinderEntry.Value.Shown) { _logger.LogInformation("Hiding Lightfinder entry"); _lightfinderEntry.Value.Shown = false; } _lightfinderText = null; _lightfinderTooltip = null; _lightfinderColors = default; } private IDtrBarEntry CreateStatusEntry() { _logger.LogTrace("Creating status DtrBar entry"); var entry = _dtrBar.Get("Lightless Sync"); entry.OnClick = interactionEvent => OnStatusEntryClick(interactionEvent); return entry; } private IDtrBarEntry CreateLightfinderEntry() { _logger.LogTrace("Creating Lightfinder DtrBar entry"); var entry = _dtrBar.Get("Lightfinder"); entry.OnClick = interactionEvent => OnLightfinderEntryClick(interactionEvent); return entry; } private void OnStatusEntryClick(DtrInteractionEvent interactionEvent) { if (interactionEvent.ClickType.Equals(MouseClickType.Left)) { if (interactionEvent.ModifierKeys.HasFlag(ClickModifierKeys.Shift)) { _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); } else { _lightlessMediator.Publish(new UiToggleMessage(typeof(CompactUi))); } return; } if (interactionEvent.ClickType.Equals(MouseClickType.Right)) { bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) { if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) { _serverManager.CurrentServer.FullPause = true; _serverManager.Save(); } else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) { _serverManager.CurrentServer.FullPause = false; _serverManager.Save(); } _ = _apiController.CreateConnectionsAsync(); } } } private void OnLightfinderEntryClick(DtrInteractionEvent interactionEvent) { if (!_configService.Current.ShowLightfinderInDtr) return; if (interactionEvent.ClickType.Equals(MouseClickType.Left)) { _broadcastService.ToggleBroadcast(); } } private async Task RunAsync() { while (!_cancellationTokenSource.IsCancellationRequested) { await Task.Delay(1000, _cancellationTokenSource.Token).ConfigureAwait(false); Update(); } } private void Update() { var config = _configService.Current; if (!config.HasValidSetup()) { HideStatusEntry(); HideLightfinderEntry(); return; } if (config.EnableDtrEntry) UpdateStatusEntry(config); else HideStatusEntry(); if (config.ShowLightfinderInDtr) UpdateLightfinderEntry(config); else HideLightfinderEntry(); } private void UpdateStatusEntry(LightlessConfig config) { string text; string tooltip; Colors colors; if (_apiController.IsConnected) { var snapshot = _pairUiService.GetSnapshot(); var visiblePairsQuery = snapshot.PairsByUid.Values.Where(x => x.IsVisible && !x.IsPaused); var pairCount = visiblePairsQuery.Count(); text = $"\uE044 {pairCount}"; if (pairCount > 0) { var preferNote = config.PreferNoteInDtrTooltip; var showUid = config.ShowUidInDtrTooltip; IEnumerable visiblePairs = showUid ? visiblePairsQuery.Select(x => string.Format("{0} ({1})", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID)) : visiblePairsQuery.Select(x => string.Format("{0}", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName)); tooltip = $"Lightless Sync: Connected{Environment.NewLine}----------{Environment.NewLine}{string.Join(Environment.NewLine, visiblePairs)}"; colors = config.DtrColorsPairsInRange; } else { tooltip = "Lightless Sync: Connected"; colors = config.DtrColorsDefault; } } else { text = "\uE044 \uE04C"; tooltip = "Lightless Sync: Not Connected"; colors = config.DtrColorsNotConnected; } if (!config.UseColorsInDtr) colors = default; var statusEntry = _statusEntry.Value; if (!statusEntry.Shown) { _logger.LogInformation("Showing status entry"); statusEntry.Shown = true; } bool statusNeedsUpdate = !string.Equals(text, _statusText, StringComparison.Ordinal) || !string.Equals(tooltip, _statusTooltip, StringComparison.Ordinal) || colors != _statusColors; if (statusNeedsUpdate) { statusEntry.Text = BuildColoredSeString(text, colors); statusEntry.Tooltip = tooltip; _statusText = text; _statusTooltip = tooltip; _statusColors = colors; } } private void UpdateLightfinderEntry(LightlessConfig config) { var lightfinderEntry = _lightfinderEntry.Value; if (!lightfinderEntry.Shown) { _logger.LogInformation("Showing Lightfinder entry"); lightfinderEntry.Shown = true; } var indicator = BuildLightfinderIndicator(); var lightfinderText = indicator.Text ?? string.Empty; var lightfinderColors = config.UseLightfinderColorsInDtr ? indicator.Colors : default; var lightfinderTooltip = BuildLightfinderTooltip(indicator.Tooltip); bool lightfinderNeedsUpdate = !string.Equals(lightfinderText, _lightfinderText, StringComparison.Ordinal) || !string.Equals(lightfinderTooltip, _lightfinderTooltip, StringComparison.Ordinal) || lightfinderColors != _lightfinderColors; if (lightfinderNeedsUpdate) { lightfinderEntry.Text = BuildColoredSeString(lightfinderText, lightfinderColors); lightfinderEntry.Tooltip = lightfinderTooltip; _lightfinderText = lightfinderText; _lightfinderTooltip = lightfinderTooltip; _lightfinderColors = lightfinderColors; } } private string? GetLocalHashedCid() { var now = DateTime.UtcNow; lock (_localHashedCidLock) { if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration) { return _localHashedCid; } } QueueLocalHashedCidRefresh(); lock (_localHashedCidLock) { return _localHashedCid; } } private void QueueLocalHashedCidRefresh() { if (Interlocked.Exchange(ref _localHashedCidRefreshActive, 1) != 0) { return; } _ = Task.Run(async () => { try { var cid = _dalamudUtilService.GetCID(); var hashedCid = cid.ToString().GetHash256(); lock (_localHashedCidLock) { _localHashedCid = hashedCid; _localHashedCidFetchedAt = DateTime.UtcNow; } } catch (Exception ex) { var now = DateTime.UtcNow; lock (_localHashedCidLock) { if (now >= _localHashedCidNextErrorLog) { _logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry."); _localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown; } _localHashedCid = null; _localHashedCidFetchedAt = now; } } finally { Interlocked.Exchange(ref _localHashedCidRefreshActive, 0); } }); } private List GetNearbyBroadcasts() { try { var localHashedCid = GetLocalHashedCid(); return [.. _broadcastScannerService .GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid) .Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)]; } catch (Exception ex) { var now = DateTime.UtcNow; if (now >= _pairRequestNextErrorLog) { _logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry."); _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; } return []; } } private IReadOnlyList GetPendingPairRequest() { try { return _pairRequestService.GetActiveRequests(); } catch (Exception ex) { var now = DateTime.UtcNow; if (now >= _pairRequestNextErrorLog) { _logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry."); _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; } return []; } } private (string Text, Colors Colors, string Tooltip) BuildLightfinderIndicator() { var config = _configService.Current; const string icon = "\uE048"; if (!_broadcastService.IsLightFinderAvailable) { return ($"{icon} --", SwapColorChannels(config.DtrColorsLightfinderUnavailable), "Lightfinder - Unavailable on this server."); } if (_broadcastService.IsBroadcasting) { switch (config.LightfinderDtrDisplayMode) { case LightfinderDtrDisplayMode.PendingPairRequests: { return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled)); } default: { return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled)); } } } var tooltip = new StringBuilder("Lightfinder - Disabled"); var colors = SwapColorChannels(config.DtrColorsLightfinderDisabled); if (_broadcastService.RemainingCooldown is { } cooldown && cooldown > TimeSpan.Zero) { tooltip.AppendLine(); tooltip.Append("Cooldown: ").Append(Math.Ceiling(cooldown.TotalSeconds)).Append("s"); colors = SwapColorChannels(config.DtrColorsLightfinderCooldown); } return ($"{icon} OFF", colors, tooltip.ToString()); } private static (string, Colors, string) FormatTooltip(string title, IEnumerable names, string icon, Colors color) { var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList(); var tooltip = new StringBuilder() .Append($"Lightfinder - Enabled{Environment.NewLine}") .Append($"{title}: {list.Count}{Environment.NewLine}") .AppendJoin(Environment.NewLine, list) .ToString(); return ($"{icon} {list.Count}", color, tooltip); } private static string BuildLightfinderTooltip(string baseTooltip) { var builder = new StringBuilder(); if (!string.IsNullOrWhiteSpace(baseTooltip)) builder.Append(baseTooltip.TrimEnd()); else builder.Append("Lightfinder status unavailable."); return builder.ToString().TrimEnd(); } private static void AppendColoredSegment(SeStringBuilder builder, string? text, Colors colors) { if (string.IsNullOrEmpty(text)) return; if (colors.Foreground != default) builder.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground)); if (colors.Glow != default) builder.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow)); builder.AddText(text); if (colors.Glow != default) builder.Add(BuildColorEndPayload(_colorTypeGlow)); if (colors.Foreground != default) builder.Add(BuildColorEndPayload(_colorTypeForeground)); } #region Colored SeString private const byte _colorTypeForeground = 0x13; private const byte _colorTypeGlow = 0x14; internal static Colors SwapColorChannels(Colors colors) => new(SwapColorComponent(colors.Foreground), SwapColorComponent(colors.Glow)); private static uint SwapColorComponent(uint color) { if (color == 0) return 0; return ((color & 0xFFu) << 16) | (color & 0xFF00u) | ((color >> 16) & 0xFFu); } private static SeString BuildColoredSeString(string text, Colors colors) { var ssb = new SeStringBuilder(); AppendColoredSegment(ssb, text, colors); return ssb.Build(); } private static RawPayload BuildColorStartPayload(byte colorType, uint color) => new(unchecked([ 0x02, colorType, 0x05, 0xF6, byte.Max((byte)color, (byte)0x01), byte.Max((byte)(color >> 8), (byte)0x01), byte.Max((byte)(color >> 16), (byte)0x01), 0x03 ])); private static RawPayload BuildColorEndPayload(byte colorType) => new([0x02, colorType, 0x02, 0xEC, 0x03]); [StructLayout(LayoutKind.Sequential)] public readonly record struct Colors(uint Foreground = default, uint Glow = default); #endregion }