diff --git a/LightlessAPI b/LightlessAPI index a337481..fd4cd52 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit a337481243a11490f3a115ca1ac0abfdd62c0554 +Subproject commit fd4cd52d2e78c8a621e6b06149e69842bb5ff255 diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 002bf1a..235f0c5 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -20,6 +20,7 @@ public class LightlessConfig : ILightlessConfiguration public DtrEntry.Colors DtrColorsDefault { get; set; } = default; public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu); public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u); + public bool UseLightlessRedesign { get; set; } = true; public bool EnableRightClickMenus { get; set; } = true; public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both; public string ExportFolder { get; set; } = string.Empty; @@ -66,4 +67,8 @@ public class LightlessConfig : ILightlessConfiguration public bool UseFocusTarget { get; set; } = false; public bool overrideFriendColor { get; set; } = false; public bool overridePartyColor { get; set; } = false; + public bool BroadcastEnabled { get; set; } = false; + public DateTime BroadcastTtl { get; set; } = DateTime.MinValue; + public bool SyncshellFinderEnabled { get; set; } = false; + public string? SelectedFinderSyncshell { get; set; } = null; } \ No newline at end of file diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 21304eb..344e968 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.11.12 + 1.12.0 https://github.com/Light-Public-Syncshells/LightlessClient diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 4f704f5..1cab3dd 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -41,7 +41,7 @@ public sealed class Plugin : IDalamudPlugin IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui, IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager, ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig, - ISigScanner sigScanner, INamePlateGui namePlateGui) + ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle) { if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName)) Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName); @@ -90,6 +90,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(new WindowSystem("LightlessSync")); collection.AddSingleton(); collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true)); + collection.AddSingleton(gameGui); // add lightless related singletons collection.AddSingleton(); @@ -144,6 +145,9 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(s => new PairManager(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), contextMenu)); collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(addonLifecycle); + collection.AddSingleton(p => new ContextMenu(contextMenu, pluginInterface, gameData, p.GetRequiredService>(), p.GetRequiredService(), p.GetRequiredService(), objectTable)); collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService>(), pluginInterface, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService>(), pluginInterface, @@ -174,7 +178,12 @@ public sealed class Plugin : IDalamudPlugin httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build)); return httpClient; }); - collection.AddSingleton((s) => new LightlessConfigService(pluginInterface.ConfigDirectory.FullName)); + collection.AddSingleton((s) => + { + var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName); + LightlessSync.UI.Style.MainStyle.Init(cfg); + return cfg; + }); collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName)); @@ -194,8 +203,8 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); + collection.AddSingleton(); // add scoped services collection.AddScoped(); @@ -218,6 +227,8 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); + collection.AddScoped((s) => new BroadcastUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); @@ -235,7 +246,7 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), namePlateGui, clientState, - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); @@ -248,6 +259,8 @@ public sealed class Plugin : IDalamudPlugin collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); }) .Build(); diff --git a/LightlessSync/Services/BroadcastService.cs b/LightlessSync/Services/BroadcastService.cs new file mode 100644 index 0000000..7e887ed --- /dev/null +++ b/LightlessSync/Services/BroadcastService.cs @@ -0,0 +1,375 @@ +using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.Mediator; +using LightlessSync.Utils; +using LightlessSync.WebAPI; +using LightlessSync.WebAPI.SignalR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Services; +public class BroadcastService : IHostedService, IMediatorSubscriber +{ + private readonly ILogger _logger; + private readonly ApiController _apiController; + private readonly LightlessMediator _mediator; + private readonly HubFactory _hubFactory; + private readonly LightlessConfigService _config; + private readonly DalamudUtilService _dalamudUtil; + public LightlessMediator Mediator => _mediator; + + public bool IsLightFinderAvailable { get; private set; } = true; + + public bool IsBroadcasting => _config.Current.BroadcastEnabled; + private bool _syncedOnStartup = false; + private bool _waitingForTtlFetch = false; + private TimeSpan? _remainingTtl = null; + private DateTime _lastTtlCheck = DateTime.MinValue; + private DateTime _lastForcedDisableTime = DateTime.MinValue; + private static readonly TimeSpan DisableCooldown = TimeSpan.FromSeconds(5); + public TimeSpan? RemainingTtl => _remainingTtl; + public TimeSpan? RemainingCooldown + { + get + { + var elapsed = DateTime.UtcNow - _lastForcedDisableTime; + if (elapsed >= DisableCooldown) return null; + return DisableCooldown - elapsed; + } + } + public BroadcastService(ILogger logger, LightlessMediator mediator, HubFactory hubFactory, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController) + { + _logger = logger; + _mediator = mediator; + _hubFactory = hubFactory; + _config = config; + _dalamudUtil = dalamudUtil; + _apiController = apiController; + } + private async Task RequireConnectionAsync(string context, Func action) + { + if (!_apiController.IsConnected) + { + _logger.LogDebug($"{context} skipped, not connected"); + return; + } + await action().ConfigureAwait(false); + } + public async Task StartAsync(CancellationToken cancellationToken) + { + _mediator.Subscribe(this, OnEnableBroadcast); + _mediator.Subscribe(this, OnBroadcastStatusChanged); + _mediator.Subscribe(this, OnTick); + + _apiController.OnConnected += () => _ = CheckLightfinderSupportAsync(cancellationToken); + _ = CheckLightfinderSupportAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _mediator.UnsubscribeAll(this); + _apiController.OnConnected -= () => _ = CheckLightfinderSupportAsync(cancellationToken); + return Task.CompletedTask; + } + + // need to rework this, this is cooked + private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken) + { + try + { + while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested) + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + return; + + var hub = _hubFactory.GetOrCreate(CancellationToken.None); + var dummy = "0".PadLeft(64, '0'); + + await hub.InvokeAsync("IsUserBroadcasting", dummy, cancellationToken); + await hub.InvokeAsync("SetBroadcastStatus", dummy, true, null, cancellationToken); + await hub.InvokeAsync("GetBroadcastTtl", dummy, cancellationToken); + await hub.InvokeAsync>("AreUsersBroadcasting", new[] { dummy }, cancellationToken); + + IsLightFinderAvailable = true; + _logger.LogInformation("Lightfinder is available."); + } + catch (HubException ex) when (ex.Message.Contains("Method does not exist")) + { + _logger.LogWarning("Lightfinder unavailable: required method missing."); + IsLightFinderAvailable = false; + + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Lightfinder check was canceled."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Lightfinder check failed."); + IsLightFinderAvailable = false; + + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + } + + private void OnEnableBroadcast(EnableBroadcastMessage msg) + { + _ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () => + { + try + { + GroupBroadcastRequestDto? groupDto = null; + if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null) + { + groupDto = new GroupBroadcastRequestDto + { + HashedCID = msg.HashedCid, + GID = _config.Current.SelectedFinderSyncshell, + Enabled = msg.Enabled, + }; + } + + _ = _apiController.SetBroadcastStatus(msg.HashedCid, msg.Enabled, groupDto).ConfigureAwait(false); + + _logger.LogInformation("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid); + + if (!msg.Enabled) + { + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + return; + } + + _waitingForTtlFetch = true; + + var ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false); + + if (ttl is { } remaining && remaining > TimeSpan.Zero) + { + _config.Current.BroadcastTtl = DateTime.UtcNow + remaining; + _config.Current.BroadcastEnabled = true; + _config.Save(); + + _logger.LogInformation("Fetched TTL from server: {TTL}", remaining); + _mediator.Publish(new BroadcastStatusChangedMessage(true, remaining)); + } + else + { + _logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling."); + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + + _waitingForTtlFetch = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid); + _waitingForTtlFetch = false; + } + }); + } + + private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) + { + _config.Current.BroadcastEnabled = msg.Enabled; + _config.Save(); + } + + public async Task CheckIfBroadcastingAsync(string targetCid) + { + bool result = false; + await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () => + { + try + { + _logger.LogInformation("[BroadcastCheck] Checking CID: {cid}", targetCid); + + var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false); + result = info?.TTL > TimeSpan.Zero; + + + _logger.LogInformation("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid); + } + }).ConfigureAwait(false); + + + return result; + } + + public async Task GetBroadcastTtlAsync(string cid) + { + TimeSpan? ttl = null; + await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => { + try + { + ttl = await _apiController.GetBroadcastTtl(cid).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch broadcast TTL for {cid}", cid); + } + }).ConfigureAwait(false); + return ttl; + } + + public async Task> AreUsersBroadcastingAsync(List hashedCids) + { + Dictionary result = new(); + + await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () => + { + try + { + var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false); + + if (batch?.Results != null) + { + foreach (var kv in batch.Results) + result[kv.Key] = kv.Value; + } + + _logger.LogInformation("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to batch check broadcast status"); + } + }).ConfigureAwait(false); + + return result; + } + + + + public async void ToggleBroadcast() + { + if (!IsLightFinderAvailable) + { + _logger.LogWarning("ToggleBroadcast - Lightfinder is not available."); + return; + } + + await RequireConnectionAsync(nameof(ToggleBroadcast), async () => + { + var cooldown = RemainingCooldown; + if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero) + { + _logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds); + return; + } + + var hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + + try + { + var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false); + var newStatus = !isCurrentlyBroadcasting; + + if (!newStatus) + { + _lastForcedDisableTime = DateTime.UtcNow; + _logger.LogInformation("Manual disable: cooldown timer started."); + } + + _logger.LogInformation("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus); + + _mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to determine current broadcast status for toggle"); + } + }).ConfigureAwait(false); + } + + private async void OnTick(PriorityFrameworkUpdateMessage _) + { + if (!IsLightFinderAvailable) + return; + + if (_config?.Current == null) + return; + + if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1) + return; + + _lastTtlCheck = DateTime.UtcNow; + + await RequireConnectionAsync(nameof(OnTick), async () => { + if (!_syncedOnStartup && _config.Current.BroadcastEnabled) + { + _syncedOnStartup = true; + try + { + var hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + var ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); + if (ttl is { } + remaining && remaining > TimeSpan.Zero) + { + _config.Current.BroadcastTtl = DateTime.UtcNow + remaining; + _config.Current.BroadcastEnabled = true; + _config.Save(); + _logger.LogInformation("Refreshed broadcast TTL from server on first OnTick: {TTL}", remaining); + } + else + { + _logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state."); + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh TTL in OnTick"); + } + } + if (_config.Current.BroadcastEnabled) + { + if (_waitingForTtlFetch) + { + _logger.LogDebug("OnTick skipped: waiting for TTL fetch"); + return; + } + var expiry = _config.Current.BroadcastTtl; + var remaining = expiry - DateTime.UtcNow; + _remainingTtl = remaining > TimeSpan.Zero ? remaining : null; + if (_remainingTtl == null) + { + _logger.LogInformation("Broadcast TTL expired. Disabling broadcast locally."); + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + _config.Save(); + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + } + else + { + _remainingTtl = null; + } + }).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index ea21af7..ed66506 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -313,7 +313,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false); } - private unsafe static string GetHashedCIDFromPlayerPointer(nint ptr) + public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr) { return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256(); } @@ -421,6 +421,16 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false); } + public IPlayerCharacter? GetPlayerByNameAndWorld(string name, ushort homeWorldId) + { + EnsureIsOnFramework(); + return _objectTable + .OfType() + .FirstOrDefault(p => + string.Equals(p.Name.TextValue, name, StringComparison.Ordinal) && + p.HomeWorld.RowId == homeWorldId); + } + public async Task RunOnFrameworkThread(System.Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0) { var fileName = Path.GetFileNameWithoutExtension(callerFilePath); diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 9018fe2..2f7d8d2 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -97,7 +97,9 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase; public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase; public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; - +public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; +public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; +public record SyncshellBroadcastsUpdatedMessage : MessageBase; public record VisibilityChange : MessageBase; #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs b/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs index c29192d..8d7aceb 100644 --- a/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs +++ b/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs @@ -1,4 +1,5 @@ using Dalamud.Interface.Windowing; +using LightlessSync.UI.Style; using Microsoft.Extensions.Logging; namespace LightlessSync.Services.Mediator; @@ -33,6 +34,18 @@ public abstract class WindowMediatorSubscriberBase : Window, IMediatorSubscriber GC.SuppressFinalize(this); } + public override void PreDraw() + { + base.PreDraw(); + MainStyle.PushStyle(); // internally checks ShouldUseTheme + } + + public override void PostDraw() + { + MainStyle.PopStyle(); // always attempts to pop if pushed + base.PostDraw(); + } + public override void Draw() { _performanceCollectorService.LogPerformance(this, $"Draw", DrawInternal); diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs new file mode 100644 index 0000000..cbb4777 --- /dev/null +++ b/LightlessSync/Services/NameplateHandler.cs @@ -0,0 +1,297 @@ +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using LightlessSync.Services.Mediator; +using LightlessSync.Utils; +// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! + +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Services; + +public unsafe class NameplateHandler : IMediatorSubscriber +{ + private readonly ILogger _logger; + private readonly IAddonLifecycle _addonLifecycle; + private readonly IGameGui _gameGui; + private readonly DalamudUtilService _dalamudUtil; + private readonly LightlessMediator _mediator; + public LightlessMediator Mediator => _mediator; + + private bool mEnabled = false; + private bool _needsLabelRefresh = false; + private AddonNamePlate* mpNameplateAddon = null; + private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; + + internal const uint mNameplateNodeIDBase = 0x7D99D500; + + private volatile HashSet _activeBroadcastingCids = new(); + + public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessMediator mediator) + { + _logger = logger; + _addonLifecycle = addonLifecycle; + _gameGui = gameGui; + _dalamudUtil = dalamudUtil; + _mediator = mediator; + } + + internal void Init() + { + EnableNameplate(); + _mediator.Subscribe(this, OnTick); + } + + internal void Uninit() + { + DisableNameplate(); + DestroyNameplateNodes(); + _mediator.Unsubscribe(this); + mpNameplateAddon = null; + } + + internal void EnableNameplate() + { + if (!mEnabled) + { + try + { + _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); + mEnabled = true; + } + catch (Exception e) + { + _logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}"); + DisableNameplate(); + } + } + } + + internal void DisableNameplate() + { + if (mEnabled) + { + try + { + _addonLifecycle.UnregisterListener(NameplateDrawDetour); + } + catch (Exception e) + { + _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); + } + + mEnabled = false; + HideAllNameplateNodes(); + } + } + + private void NameplateDrawDetour(AddonEvent type, AddonArgs args) + { + var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; + + if (mpNameplateAddon != pNameplateAddon) + { + for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null; + mpNameplateAddon = pNameplateAddon; + if (mpNameplateAddon != null) CreateNameplateNodes(); + } + + UpdateNameplateNodes(); + } + + private void CreateNameplateNodes() + { + for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) + { + var nameplateObject = GetNameplateObject(i); + if (nameplateObject == null) + continue; + + var pNameplateResNode = nameplateObject.Value.NameContainer; + var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare); + + if (pNewNode != null) + { + var pLastChild = pNameplateResNode->ChildNode; + while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode; + pNewNode->AtkResNode.NextSiblingNode = pLastChild; + pNewNode->AtkResNode.ParentNode = pNameplateResNode; + pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; + nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); + pNewNode->AtkResNode.SetUseDepthBasedPriority(true); + mTextNodes[i] = pNewNode; + } + } + } + + private void DestroyNameplateNodes() + { + var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; + if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon) + return; + + for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) + { + var pTextNode = mTextNodes[i]; + var pNameplateNode = GetNameplateComponentNode(i); + if (pTextNode != null && pNameplateNode != null) + { + try + { + if (pTextNode->AtkResNode.PrevSiblingNode != null) + pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode; + if (pTextNode->AtkResNode.NextSiblingNode != null) + pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; + pNameplateNode->Component->UldManager.UpdateDrawNodeList(); + pTextNode->AtkResNode.Destroy(true); + mTextNodes[i] = null; + } + catch (Exception e) + { + _logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}"); + } + } + } + } + + private void HideAllNameplateNodes() + { + for (int i = 0; i < mTextNodes.Length; ++i) + { + HideNameplateTextNode(i); + } + } + + private void UpdateNameplateNodes() + { + var framework = Framework.Instance(); + var ui3DModule = framework->GetUIModule()->GetUI3DModule(); + + if (ui3DModule == null) + return; + + for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) + { + var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value; + if (objectInfo == null || objectInfo->GameObject == null) + continue; + + var nameplateIndex = objectInfo->NamePlateIndex; + if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) + continue; + + var pNode = mTextNodes[nameplateIndex]; + if (pNode == null) + continue; + + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); + + //_logger.LogInformation($"checking cid: {cid}", cid); + + if (cid == null || !_activeBroadcastingCids.Contains(cid)) + { + pNode->AtkResNode.ToggleVisibility(false); + continue; + } + + pNode->AtkResNode.ToggleVisibility(true); + + var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; + nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); + + var nameContainer = nameplateObject.NameContainer; + var nameText = nameplateObject.NameText; + + var labelY = nameContainer->Height - nameplateObject.TextH - (int)(24 * nameText->AtkResNode.ScaleY); + + pNode->AtkResNode.SetPositionShort(58, (short)labelY); + pNode->AtkResNode.SetUseDepthBasedPriority(true); + pNode->AtkResNode.SetScale(0.5f, 0.5f); + + pNode->AtkResNode.Color.A = 255; + + pNode->TextColor.A = 255; + pNode->TextColor.R = 173; + pNode->TextColor.G = 138; + pNode->TextColor.B = 245; + + pNode->EdgeColor.A = 255; + pNode->EdgeColor.R = 0; + pNode->EdgeColor.G = 0; + pNode->EdgeColor.B = 0; + + pNode->FontSize = 24; + pNode->AlignmentType = AlignmentType.Center; + pNode->FontType = FontType.MiedingerMed; + pNode->LineSpacing = 24; + pNode->CharSpacing = 1; + + pNode->TextFlags = TextFlags.Edge | TextFlags.Glare; + + pNode->SetText("Lightfinder"); + } + } + + private void HideNameplateTextNode(int i) + { + var pNode = mTextNodes[i]; + if (pNode != null) + { + pNode->AtkResNode.ToggleVisibility(false); + } + } + + private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) + { + if (i < AddonNamePlate.NumNamePlateObjects && + mpNameplateAddon != null && + mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) + { + return mpNameplateAddon->NamePlateObjectArray[i]; + } + else + { + return null; + } + } + + private AtkComponentNode* GetNameplateComponentNode(int i) + { + var nameplateObject = GetNameplateObject(i); + return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; + } + + public void FlagRefresh() + { + _needsLabelRefresh = true; + } + + public void OnTick(PriorityFrameworkUpdateMessage _) + { + if (_needsLabelRefresh) + { + UpdateNameplateNodes(); + _needsLabelRefresh = false; + } + } + + public void UpdateBroadcastingCids(IEnumerable cids) + { + var newSet = cids.ToHashSet(); + + var changed = !_activeBroadcastingCids.SetEquals(newSet); + if (!changed) + return; + + _activeBroadcastingCids.Clear(); + foreach (var cid in newSet) + _activeBroadcastingCids.Add(cid); + + _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids)); + + FlagRefresh(); + } +} diff --git a/LightlessSync/Services/NameplateService.cs b/LightlessSync/Services/NameplateService.cs index 400ae23..73c8dd1 100644 --- a/LightlessSync/Services/NameplateService.cs +++ b/LightlessSync/Services/NameplateService.cs @@ -3,47 +3,120 @@ using Dalamud.Game.Gui.NamePlate; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; using Dalamud.Utility; +using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + namespace LightlessSync.Services; public class NameplateService : DisposableMediatorSubscriberBase { + private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly IClientState _clientState; private readonly INamePlateGui _namePlateGui; private readonly PairManager _pairManager; + private readonly BroadcastService _broadcastService; + private readonly DalamudUtilService _dalamudUtil; + private readonly NameplateHandler _nameplatehandler; + private readonly IFramework _framework; + private readonly IGameGui _gameGui; + private readonly ConcurrentDictionary _broadcastCache = new(); + private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(5); + private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1); + private readonly Queue _lookupQueue = new(); + private readonly HashSet _lookupQueuedCids = new(); + private readonly HashSet _syncshellCids = new(); + + private readonly CancellationTokenSource _cleanupCts = new(); + private Task? _cleanupTask; + + private const int MaxLookupsPerFrame = 15; + private const int MaxQueueSize = 100; + + private int _lookupsThisFrame = 0; + private int _frameCounter = 0; + + public IReadOnlyDictionary BroadcastCache => _broadcastCache; + + public readonly struct BroadcastEntry + { + public readonly bool IsBroadcasting; + public readonly DateTime ExpiryTime; + public readonly bool PrefixApplied; + public readonly string? GID; + + public BroadcastEntry(bool isBroadcasting, DateTime expiryTime, bool prefixApplied, string? gid = null) + { + IsBroadcasting = isBroadcasting; + ExpiryTime = expiryTime; + PrefixApplied = prefixApplied; + GID = gid; + } + } public NameplateService(ILogger logger, LightlessConfigService configService, INamePlateGui namePlateGui, IClientState clientState, PairManager pairManager, - LightlessMediator lightlessMediator) : base(logger, lightlessMediator) + BroadcastService broadcastService, + LightlessMediator lightlessMediator, + DalamudUtilService dalamudUtil, + NameplateHandler nameplatehandler, + IGameGui gameGui) : base(logger, lightlessMediator) { + _logger = logger; _configService = configService; _namePlateGui = namePlateGui; _clientState = clientState; _pairManager = pairManager; + _broadcastService = broadcastService; + _dalamudUtil = dalamudUtil; + _nameplatehandler = nameplatehandler; + _gameGui = gameGui; + _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; _namePlateGui.RequestRedraw(); Mediator.Subscribe(this, (_) => _namePlateGui.RequestRedraw()); + Mediator.Subscribe(this, OnBroadcastStatusChanged); + _nameplatehandler.Init(); + _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop); } private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList handlers) { - if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) return; - var visibleUsersIds = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue).Select(u => (ulong)u.PlayerCharacterId).ToHashSet(); + _frameCounter++; + _lookupsThisFrame = 0; + + if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) + return; + + var visibleUsersIds = _pairManager.GetOnlineUserPairs() + .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) + .Select(u => (ulong)u.PlayerCharacterId) + .ToHashSet(); + + var now = DateTime.UtcNow; var colors = _configService.Current.NameplateColors; + foreach (var handler in handlers) { var playerCharacter = handler.PlayerCharacter; - if (playerCharacter == null) { continue; } + if (playerCharacter == null) + continue; + + + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(playerCharacter.Address); + var hasEntry = _broadcastCache.TryGetValue(cid, out var entry); + var isEntryStale = !hasEntry || entry.ExpiryTime <= now; + var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend); bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty); @@ -55,9 +128,171 @@ public class NameplateService : DisposableMediatorSubscriberBase (isFriend && !friendColorAllowed) )) { + _logger.LogInformation("added nameplate color to {Name}", playerCharacter.Name.TextValue); handler.NameParts.TextWrap = CreateTextWrap(colors); } + + if (!_broadcastService.IsBroadcasting) + continue; + + if (isEntryStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize) + { + _lookupQueue.Enqueue(cid); + } } + + if (_broadcastService.IsBroadcasting && _frameCounter % 2 == 0) + { + var cidsToLookup = new List(); + while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame) + { + var nextCid = _lookupQueue.Dequeue(); + _lookupQueuedCids.Remove(nextCid); + cidsToLookup.Add(nextCid); + _lookupsThisFrame++; + } + + if (cidsToLookup.Count > 0) + _ = BatchUpdateBroadcastCacheAsync(cidsToLookup); + } + } + + private async Task BatchUpdateBroadcastCacheAsync(List cidList) + { + var results = await _broadcastService.AreUsersBroadcastingAsync(cidList).ConfigureAwait(false); + var now = DateTime.UtcNow; + + foreach (var (cid, info) in results) + { + if (string.IsNullOrWhiteSpace(cid) || info == null) + { + _logger.LogWarning("Skipping broadcast entry: cid={Cid}, info=null or empty", cid); + continue; + } + + bool isBroadcasting = info.IsBroadcasting; + TimeSpan effectiveTtl = isBroadcasting && info.TTL.HasValue + ? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks)) + : RetryDelay; + + var expiryTime = now + effectiveTtl; + + _broadcastCache.AddOrUpdate(cid, + new BroadcastEntry(isBroadcasting, expiryTime, false, info.GID), + (_, old) => new BroadcastEntry(isBroadcasting, expiryTime, old.PrefixApplied, info.GID)); + } + + var activeCids = _broadcastCache + .Where(kvp => kvp.Value.IsBroadcasting) + .Select(kvp => kvp.Key) + .ToList(); + + _nameplatehandler.UpdateBroadcastingCids(activeCids); + _namePlateGui.RequestRedraw(); + + UpdateSyncshellBroadcasts(); + } + + private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) + { + if (!msg.Enabled) + { + _logger.LogInformation("Broadcast disabled, clearing prefix cache and queue"); + + _broadcastCache.Clear(); + _lookupQueue.Clear(); + _lookupQueuedCids.Clear(); + _syncshellCids.Clear(); + + _nameplatehandler.UpdateBroadcastingCids(Enumerable.Empty()); + _namePlateGui.RequestRedraw(); + } + } + + public List GetActiveSyncshellBroadcasts() + { + var now = DateTime.UtcNow; + + return _broadcastCache + .Where(kvp => + kvp.Value.IsBroadcasting && + kvp.Value.ExpiryTime > now && + !string.IsNullOrEmpty(kvp.Value.GID)) + .Select(kvp => new BroadcastStatusInfoDto + { + HashedCID = kvp.Key, + IsBroadcasting = true, + TTL = kvp.Value.ExpiryTime - now, + GID = kvp.Value.GID + }) + .ToList(); + } + + private void UpdateSyncshellBroadcasts() + { + var now = DateTime.UtcNow; + + var newSet = _broadcastCache + .Where(kvp => kvp.Value.IsBroadcasting && kvp.Value.ExpiryTime > now && !string.IsNullOrEmpty(kvp.Value.GID)) + .Select(kvp => kvp.Key) + .ToHashSet(); + + if (!_syncshellCids.SetEquals(newSet)) + { + _syncshellCids.Clear(); + foreach (var cid in newSet) + _syncshellCids.Add(cid); + + _logger.LogInformation("Syncshell broadcast entries changed, sending update lol"); + Mediator.Publish(new SyncshellBroadcastsUpdatedMessage()); + } + } + + public bool IsBroadcastingKnown(string cidHash, out bool isBroadcasting) + { + if (_broadcastCache.TryGetValue(cidHash, out var entry)) + { + isBroadcasting = entry.IsBroadcasting; + return true; + } + + isBroadcasting = false; + return false; + } + + private async Task ExpiredBroadcastCleanupLoop() + { + var token = _cleanupCts.Token; + + try + { + while (!token.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(10), token); + + var now = DateTime.UtcNow; + foreach (var (cid, entry) in _broadcastCache.ToArray()) + { + if (entry.ExpiryTime <= now) + { + if (_broadcastCache.TryRemove(cid, out _)) + { + _logger.LogInformation("Removed expired broadcast entry: {Cid}", cid); + } + } + } + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in ExpiredBroadcastCleanupLoop"); + } + + UpdateSyncshellBroadcasts(); } public void RequestRedraw() @@ -80,12 +315,15 @@ public class NameplateService : DisposableMediatorSubscriberBase return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString()); } - protected override void Dispose(bool disposing) { base.Dispose(disposing); + + _cleanupCts.Cancel(); + _cleanupTask?.Wait(100); + _namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate; _namePlateGui.RequestRedraw(); + _nameplatehandler.Uninit(); } -} - +} \ No newline at end of file diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs new file mode 100644 index 0000000..7d60e58 --- /dev/null +++ b/LightlessSync/UI/BroadcastUI.cs @@ -0,0 +1,384 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using Dalamud.Utility; +using LightlessSync.API.Dto.Group; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Logging; +using System.Numerics; + +namespace LightlessSync.UI +{ + public class BroadcastUI : WindowMediatorSubscriberBase + { + private readonly ApiController _apiController; + private readonly LightlessConfigService _configService; + private readonly BroadcastService _broadcastService; + private readonly UiSharedService _uiSharedService; + private readonly NameplateService _nameplateService; + + private IReadOnlyList _allSyncshells; + private string _userUid = string.Empty; + + private List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + + public BroadcastUI( + ILogger logger, + LightlessMediator mediator, + PerformanceCollectorService performanceCollectorService, + BroadcastService broadcastService, + LightlessConfigService configService, + UiSharedService uiShared, + ApiController apiController, + NameplateService nameplateService + ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) + { + _broadcastService = broadcastService; + _uiSharedService = uiShared; + _configService = configService; + _apiController = apiController; + _nameplateService = nameplateService; + + IsOpen = false; + this.SizeConstraints = new() + { + MinimumSize = new(590, 340), + MaximumSize = new(590, 340) + }; + + mediator.Subscribe(this, async _ => await RefreshSyncshells()); + } + + private void RebuildSyncshellDropdownOptions() + { + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var allSyncshells = _allSyncshells ?? Array.Empty(); + var ownedSyncshells = allSyncshells + .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal)) + .ToList(); + + _syncshellOptions.Clear(); + _syncshellOptions.Add(("None", null, true)); + + var addedGids = new HashSet(); + + foreach (var shell in ownedSyncshells) + { + var label = shell.GroupAliasOrGID ?? shell.GID; + _syncshellOptions.Add((label, shell.GID, true)); + addedGids.Add(shell.GID); + } + + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + { + var matching = allSyncshells.FirstOrDefault(g => g.GID == selectedGid); + if (matching != null) + { + var label = matching.GroupAliasOrGID ?? matching.GID; + _syncshellOptions.Add((label, matching.GID, true)); + addedGids.Add(matching.GID); + } + } + + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + { + _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + } + } + + public Task RefreshSyncshells() + { + return RefreshSyncshellsInternal(); + } + + private async Task RefreshSyncshellsInternal() + { + if (!_apiController.IsConnected) + { + _allSyncshells = Array.Empty(); + RebuildSyncshellDropdownOptions(); + return; + } + + try + { + _allSyncshells = await _apiController.GroupsGetAll(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch Syncshells."); + _allSyncshells = Array.Empty(); + } + + RebuildSyncshellDropdownOptions(); + } + + + public override void OnOpen() + { + _userUid = _apiController.UID; + _ = RefreshSyncshellsInternal(); + } + + protected override void DrawInternal() + { + if (!_broadcastService.IsLightFinderAvailable) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.TextWrapped("This server doesn't support LightFinder."); + ImGui.PopStyleColor(); + + ImGuiHelpers.ScaledDummy(0.25f); + } + + if (ImGui.BeginTabBar("##MyTabBar")) + { + if (ImGui.BeginTabItem("Lightfinder")) + { + _uiSharedService.MediumText("Lightfinder", UIColors.Get("PairBlue")); + + ImGui.PushTextWrapPos(); + ImGui.Text("This lets other Lightless users know you use Lightless."); + ImGui.Text("By enabling this, the server will allow other people to see that you are using Lightless."); + ImGui.Text("When disabled, pairing is still possible but both parties need to mutually send each other requests, receiving party will not be notified about the request unless the pairing is complete."); + ImGui.Text("At no point ever, even when Lightfinder is active that any Lightless data is getting sent to other people (including ID's), the server keeps this to itself."); + ImGui.Text("You can request to pair by right-clicking any (not yourself) character and using 'Send Pair Request'."); + ImGui.PopTextWrapPos(); + + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("Use it only when you want to be visible."); + ImGui.PopStyleColor(); + + ImGuiHelpers.ScaledDummy(0.2f); + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + + if (_broadcastService.IsBroadcasting) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen")); + ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe.. + ImGui.PopStyleColor(); + + var ttl = _broadcastService.RemainingTtl; + if (ttl is { } remaining && remaining > TimeSpan.Zero) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}"); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe.. + ImGui.PopStyleColor(); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe.. + ImGui.PopStyleColor(); + } + + var cooldown = _broadcastService.RemainingCooldown; + if (cooldown is { } cd) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)"); + ImGui.PopStyleColor(); + } + + ImGuiHelpers.ScaledDummy(0.5f); + + bool isBroadcasting = _broadcastService.IsBroadcasting; + bool isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); + + if (isOnCooldown) + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); + else if (isBroadcasting) + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + else + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue")); + + if (isOnCooldown) + ImGui.BeginDisabled(); + + string buttonText = isBroadcasting ? "Disable Lightfinder" : "Enable Lightfinder"; + + if (ImGui.Button(buttonText, new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) + { + _broadcastService.ToggleBroadcast(); + } + + if (isOnCooldown) + ImGui.EndDisabled(); + + ImGui.PopStyleColor(); + ImGui.PopStyleVar(); + + ImGui.EndTabItem(); + } + + if (ImGui.BeginTabItem("Syncshell Finder")) + { + if (_allSyncshells == null) + { + ImGui.Text("Loading Syncshells..."); + return; + } + + _uiSharedService.MediumText("Syncshell Finder", UIColors.Get("PairBlue")); + + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + + ImGui.PushTextWrapPos(); + ImGui.Text("Allow your owned Syncshell to be indexed by the Nearby Syncshell Finder."); + ImGui.Text("To enable this, select one of your owned Syncshells from the dropdown menu below and ensure that \"Toggle Syncshell Finder\" is enabled. Your Syncshell will be visible in the Nearby Syncshell Finder as long as Lightfinder is active."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(0.2f); + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + + bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; + bool isBroadcasting = _broadcastService.IsBroadcasting; + + if (isBroadcasting) + ImGui.BeginDisabled(); + + if (ImGui.Checkbox("Toggle Syncshell Finder", ref ShellFinderEnabled)) + { + _configService.Current.SyncshellFinderEnabled = ShellFinderEnabled; + _configService.Save(); + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("Toggle to broadcast specified Syncshell."); + ImGui.EndTooltip(); + } + + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var currentOption = _syncshellOptions.FirstOrDefault(o => o.GID == selectedGid); + var preview = currentOption.Label ?? "Select a Syncshell..."; + + if (ImGui.BeginCombo("##SyncshellDropdown", preview)) + { + foreach (var (label, gid, available) in _syncshellOptions) + { + bool isSelected = gid == selectedGid; + + if (!available) + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + + if (ImGui.Selectable(label, isSelected)) + { + _configService.Current.SelectedFinderSyncshell = gid; + _configService.Save(); + } + + if (!available && ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("This Syncshell is not available on the current service."); + ImGui.EndTooltip(); + } + + if (!available) + ImGui.PopStyleColor(); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("Choose one of the available options."); + ImGui.EndTooltip(); + } + + + if (isBroadcasting) + ImGui.EndDisabled(); + + ImGui.EndTabItem(); + } + + if (ImGui.BeginTabItem("Debug")) + { + ImGui.Text("Broadcast Cache"); + + if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 200f))) + { + ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + var now = DateTime.UtcNow; + + foreach (var (cid, entry) in _nameplateService.BroadcastCache) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(cid.Truncate(12)); + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(cid); + ImGui.EndTooltip(); + } + + ImGui.TableNextColumn(); + var colorBroadcast = entry.IsBroadcasting + ? UIColors.Get("LightlessGreen") + : UIColors.Get("DimRed"); + + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); + ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); + + ImGui.TableNextColumn(); + var remaining = entry.ExpiryTime - now; + var colorTtl = + remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") : + remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") : + (Vector4?)null; + + if (colorTtl != null) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); + + ImGui.TextUnformatted(remaining > TimeSpan.Zero + ? remaining.ToString("hh\\:mm\\:ss") + : "Expired"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.GID ?? "-"); + } + + ImGui.EndTable(); + } + + + ImGui.EndTabItem(); + } + + ImGui.EndTabBar(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } + } +} diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 679c5f2..1d2105a 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -51,6 +51,8 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly TopTabMenu _tabMenu; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; + private readonly BroadcastService _broadcastService; + private List _drawFolders; private Dictionary>? _cachedAnalysis; private Pair? _lastAddedUser; @@ -62,13 +64,28 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _wasOpen; private float _windowContentWidth; - public CompactUi(ILogger logger, UiSharedService uiShared, LightlessConfigService configService, ApiController apiController, PairManager pairManager, - ServerConfigurationManager serverManager, LightlessMediator mediator, FileUploadManager fileTransferManager, - TagHandler tagHandler, DrawEntityFactory drawEntityFactory, - SelectTagForPairUi selectTagForPairUi, SelectPairForTagUi selectPairForTagUi, RenamePairTagUi renameTagUi, - SelectTagForSyncshellUi selectTagForSyncshellUi, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, - PerformanceCollectorService performanceCollectorService, IpcManager ipcManager, CharacterAnalyzer characterAnalyzer, PlayerPerformanceConfigService playerPerformanceConfig, LightlessMediator lightlessMediator) - : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) + public CompactUi( + ILogger logger, + UiSharedService uiShared, + LightlessConfigService configService, + ApiController apiController, + PairManager pairManager, + ServerConfigurationManager serverManager, + LightlessMediator mediator, + FileUploadManager fileTransferManager, + TagHandler tagHandler, + DrawEntityFactory drawEntityFactory, + SelectTagForPairUi selectTagForPairUi, + SelectPairForTagUi selectPairForTagUi, + RenamePairTagUi renameTagUi, + SelectTagForSyncshellUi selectTagForSyncshellUi, + SelectSyncshellForTagUi selectSyncshellForTagUi, + RenameSyncshellTagUi renameSyncshellTagUi, + PerformanceCollectorService performanceCollectorService, + IpcManager ipcManager, + BroadcastService broadcastService, + CharacterAnalyzer characterAnalyzer, + PlayerPerformanceConfigService playerPerformanceConfig) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; _configService = configService; @@ -85,6 +102,7 @@ public class CompactUi : WindowMediatorSubscriberBase _selectPairsForGroupUi = selectPairForTagUi; _renamePairTagUi = renameTagUi; _ipcManager = ipcManager; + _broadcastService = broadcastService; _tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService); AllowPinning = true; @@ -120,6 +138,89 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.Text("Open Lightless Event Viewer"); ImGui.EndTooltip(); } + }, + new TitleBarButton() + { + Icon = FontAwesomeIcon.TowerCell, + Click = (_) => + { + Mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + }, + IconOffset = new(2, 1), + + ShowTooltip = () => + { + ImGui.BeginTooltip(); + + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue")); + ImGui.Text("Lightfinder"); + ImGui.PopStyleColor(); + + ImGui.Text("This lets other Lightless users know you use Lightless."); + ImGui.Text("By enabling this, the server will allow other people to see that you are using Lightless."); + ImGui.Text("When disabled, pairing is still possible but both parties need to mutually send each other requests, receiving party will not be notified about the request unless the pairing is complete."); + ImGui.Text("At no point ever, even when Lightfinder is active that any Lightless data is getting sent to other people (including ID's), the server keeps this to itself."); + ImGui.Text("You can request to pair by right-clicking any (not yourself) character and using 'Send Pair Request'."); + + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("Use it only when you want to be visible."); + ImGui.PopStyleColor(); + + ImGuiHelpers.ScaledDummy(0.2f); + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + + if (_broadcastService.IsBroadcasting) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen")); + ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe.. + ImGui.PopStyleColor(); + + var ttl = _broadcastService.RemainingTtl; + if (ttl is { } remaining && remaining > TimeSpan.Zero) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}"); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("The Lightfinder's light wanes, but not in vain."); // cringe.. + ImGui.PopStyleColor(); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe.. + ImGui.PopStyleColor(); + } + + var cooldown = _broadcastService.RemainingCooldown; + if (cooldown is { } cd) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)"); + ImGui.PopStyleColor(); + } + + ImGui.EndTooltip(); + } + }, + new TitleBarButton() + { + Icon = FontAwesomeIcon.NetworkWired, + Click = (msg) => + { + Mediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI))); + }, + IconOffset = new(2,1), + ShowTooltip = () => + { + ImGui.BeginTooltip(); + ImGui.Text("Nearby Syncshells"); + ImGui.EndTooltip(); + } } }; @@ -151,7 +252,7 @@ public class CompactUi : WindowMediatorSubscriberBase }; _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; - _lightlessMediator = lightlessMediator; + _lightlessMediator = mediator; } protected override void DrawInternal() @@ -202,7 +303,7 @@ public class CompactUi : WindowMediatorSubscriberBase } using (ImRaii.PushId("header")) DrawUIDHeader(); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + _uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f); using (ImRaii.PushId("serverstatus")) DrawServerStatus(); ImGui.Separator(); diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 398af1a..fdd06e8 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -1,6 +1,5 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Extensions; @@ -12,6 +11,7 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Handlers; +using LightlessSync.Utils; using LightlessSync.WebAPI; namespace LightlessSync.UI.Components; @@ -295,6 +295,31 @@ public class DrawUserPair } ImGui.SameLine(); + + if (_pair.UserData.IsAdmin || _pair.UserData.IsModerator) + { + ImGui.SameLine(); + + var iconId = _pair.UserData.IsAdmin ? 67 : 68; + var colorKey = _pair.UserData.IsAdmin ? "LightlessAdminText" : "LightlessModeratorText"; + var roleColor = UIColors.Get(colorKey); + + var iconPos = ImGui.GetCursorScreenPos(); + SeStringUtils.RenderIconWithHitbox(iconId, iconPos); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + using (ImRaii.PushColor(ImGuiCol.Text, roleColor)) + { + ImGui.TextUnformatted(_pair.UserData.IsAdmin + ? "Official Lightless Admin" + : "Official Lightless Moderator"); + } + ImGui.EndTooltip(); + } + } + } private void DrawName(float leftSide, float rightSide) diff --git a/LightlessSync/UI/ContextMenu.cs b/LightlessSync/UI/ContextMenu.cs new file mode 100644 index 0000000..1e1cfc3 --- /dev/null +++ b/LightlessSync/UI/ContextMenu.cs @@ -0,0 +1,151 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using LightlessSync.Services; +using LightlessSync.Utils; +using LightlessSync.WebAPI; +using Lumina.Excel.Sheets; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.UI; + +internal class ContextMenu : IHostedService +{ + private readonly IContextMenu _contextMenu; + private readonly IDalamudPluginInterface _pluginInterface; + private readonly IDataManager _gameData; + private readonly ILogger _logger; + private readonly DalamudUtilService _dalamudUtil; + private readonly ApiController _apiController; + private readonly IObjectTable _objectTable; + + private static readonly string[] ValidAddons = new[] + { + null, + "PartyMemberList", "FriendList", "FreeCompany", "LinkShell", "CrossWorldLinkshell", + "_PartyList", "ChatLog", "LookingForGroup", "BlackList", "ContentMemberList", + "SocialList", "ContactList", "BeginnerChatList", "MuteList" + }; + + public ContextMenu( + IContextMenu contextMenu, + IDalamudPluginInterface pluginInterface, + IDataManager gameData, + ILogger logger, + DalamudUtilService dalamudUtil, + ApiController apiController, + IObjectTable objectTable) + { + _contextMenu = contextMenu; + _pluginInterface = pluginInterface; + _gameData = gameData; + _logger = logger; + _dalamudUtil = dalamudUtil; + _apiController = apiController; + _objectTable = objectTable; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _contextMenu.OnMenuOpened += OnMenuOpened; + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _contextMenu.OnMenuOpened -= OnMenuOpened; + return Task.CompletedTask; + } + + public void Enable() + { + _contextMenu.OnMenuOpened += OnMenuOpened; + _logger.LogDebug("Context menu enabled."); + } + + public void Disable() + { + _contextMenu.OnMenuOpened -= OnMenuOpened; + _logger.LogDebug("Context menu disabled."); + } + + private void OnMenuOpened(IMenuOpenedArgs args) + { + if (!_pluginInterface.UiBuilder.ShouldModifyUi) + return; + + if (!ValidAddons.Contains(args.AddonName)) + return; + + if (args.Target is not MenuTargetDefault target) + return; + + if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) + return; + + var world = GetWorld(target.TargetHomeWorld.RowId); + if (!IsWorldValid(world)) + return; + + args.AddMenuItem(new MenuItem + { + Name = "Send Pair Request", + PrefixChar = 'L', + UseDefaultPrefix = false, + PrefixColor = 708, + OnClicked = async _ => await HandleSelection(args) + }); + } + + private async Task HandleSelection(IMenuArgs args) + { + if (args.Target is not MenuTargetDefault target) + return; + + var world = GetWorld(target.TargetHomeWorld.RowId); + if (!IsWorldValid(world)) + return; + + try + { + var targetData = _objectTable + .OfType() + .FirstOrDefault(p => + string.Equals(p.Name.TextValue, target.TargetName, StringComparison.OrdinalIgnoreCase) && + p.HomeWorld.RowId == target.TargetHomeWorld.RowId); + + if (targetData == null || targetData.Address == IntPtr.Zero) + { + _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name); + return; + } + + var senderCid = (await _dalamudUtil.GetCIDAsync()).ToString().GetHash256(); + var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); + + _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); + await _apiController.TryPairWithContentId(receiverCid, senderCid); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending pair request."); + } + } + + private World GetWorld(uint worldId) + { + var sheet = _gameData.GetExcelSheet()!; + return sheet.TryGetRow(worldId, out var world) ? world : sheet.First(); + } + + public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId)); + + public static bool IsWorldValid(World world) + { + var name = world.Name.ToString(); + return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]); + } +} diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index e77093b..81ed337 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -6,6 +6,8 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Utils; +using System.Numerics; namespace LightlessSync.UI.Handlers; @@ -89,11 +91,37 @@ public class IdDisplayHandler { ImGui.SameLine(textPosX); (bool textIsUid, string playerText) = GetPlayerText(pair); + if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal)) { ImGui.AlignTextToFramePadding(); - using (ImRaii.PushFont(UiBuilder.MonoFont, textIsUid)) ImGui.TextUnformatted(playerText); + var font = UiBuilder.MonoFont; + + var isAdmin = pair.UserData.IsAdmin; + var isModerator = pair.UserData.IsModerator; + + Vector4? textColor = isAdmin + ? UIColors.Get("LightlessAdminText") + : isModerator + ? UIColors.Get("LightlessModeratorText") + : null; + + Vector4? glowColor = isAdmin + ? UIColors.Get("LightlessAdminGlow") + : isModerator + ? UIColors.Get("LightlessModeratorGlow") + : null; + + var seString = (textColor != null || glowColor != null) + ? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor) + : SeStringUtils.BuildPlain(playerText); + + using (ImRaii.PushFont(font, textIsUid)) + { + var pos = ImGui.GetCursorScreenPos(); + SeStringUtils.RenderSeStringWithHitbox(seString, pos, font); + } if (ImGui.IsItemHovered()) { @@ -173,10 +201,12 @@ public class IdDisplayHandler { _editEntry = string.Empty; } + UiSharedService.AttachToolTip("Hit ENTER to save\nRight click to cancel"); } } + public (bool isGid, string text) GetGroupText(GroupFullInfoDto group) { var textIsGid = true; diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 3c392ba..a973c34 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -921,6 +921,7 @@ public class SettingsUi : WindowMediatorSubscriberBase var showUidInDtrTooltip = _configService.Current.ShowUidInDtrTooltip; var preferNoteInDtrTooltip = _configService.Current.PreferNoteInDtrTooltip; var useColorsInDtr = _configService.Current.UseColorsInDtr; + var useLightlessRedesign = _configService.Current.UseLightlessRedesign; var dtrColorsDefault = _configService.Current.DtrColorsDefault; var dtrColorsNotConnected = _configService.Current.DtrColorsNotConnected; var dtrColorsPairsInRange = _configService.Current.DtrColorsPairsInRange; @@ -1091,6 +1092,12 @@ public class SettingsUi : WindowMediatorSubscriberBase } } + if (ImGui.Checkbox("Use the complete redesign of the UI for Lightless client.", ref useLightlessRedesign)) + { + _configService.Current.UseLightlessRedesign = useLightlessRedesign; + _configService.Save(); + } + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs new file mode 100644 index 0000000..d40ed2e --- /dev/null +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -0,0 +1,169 @@ +// inspiration: brio because it's style is fucking amazing + +using Dalamud.Bindings.ImGui; +using LightlessSync.LightlessConfiguration; +using System.Numerics; + +namespace LightlessSync.UI.Style +{ + internal static class MainStyle + { + private static LightlessConfigService? _config; + public static void Init(LightlessConfigService config) => _config = config; + public static bool ShouldUseTheme => _config?.Current.UseLightlessRedesign ?? false; + + private static bool _hasPushed; + private static int _pushedColorCount; + private static int _pushedStyleVarCount; + + public static void PushStyle() + { + if (_hasPushed) + PopStyle(); + + if (!ShouldUseTheme) + { + _hasPushed = false; + return; + } + + _hasPushed = true; + _pushedColorCount = 0; + _pushedStyleVarCount = 0; + + Push(ImGuiCol.Text, new Vector4(255, 255, 255, 255)); + Push(ImGuiCol.TextDisabled, new Vector4(128, 128, 128, 255)); + + Push(ImGuiCol.WindowBg, new Vector4(23, 23, 23, 248)); + Push(ImGuiCol.ChildBg, new Vector4(23, 23, 23, 66)); + Push(ImGuiCol.PopupBg, new Vector4(23, 23, 23, 248)); + + Push(ImGuiCol.Border, new Vector4(65, 65, 65, 255)); + Push(ImGuiCol.BorderShadow, new Vector4(0, 0, 0, 150)); + + Push(ImGuiCol.FrameBg, new Vector4(40, 40, 40, 255)); + Push(ImGuiCol.FrameBgHovered, new Vector4(50, 50, 50, 255)); + Push(ImGuiCol.FrameBgActive, new Vector4(30, 30, 30, 255)); + + Push(ImGuiCol.TitleBg, new Vector4(24, 24, 24, 232)); + Push(ImGuiCol.TitleBgActive, new Vector4(30, 30, 30, 255)); + Push(ImGuiCol.TitleBgCollapsed, new Vector4(27, 27, 27, 255)); + + Push(ImGuiCol.MenuBarBg, new Vector4(36, 36, 36, 255)); + Push(ImGuiCol.ScrollbarBg, new Vector4(0, 0, 0, 0)); + Push(ImGuiCol.ScrollbarGrab, new Vector4(62, 62, 62, 255)); + Push(ImGuiCol.ScrollbarGrabHovered, new Vector4(70, 70, 70, 255)); + Push(ImGuiCol.ScrollbarGrabActive, new Vector4(70, 70, 70, 255)); + + Push(ImGuiCol.CheckMark, UIColors.Get("LightlessPurple")); + + Push(ImGuiCol.SliderGrab, new Vector4(101, 101, 101, 255)); + Push(ImGuiCol.SliderGrabActive, new Vector4(123, 123, 123, 255)); + + Push(ImGuiCol.Button, UIColors.Get("ButtonDefault")); + Push(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple")); + Push(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive")); + + Push(ImGuiCol.Header, new Vector4(0, 0, 0, 60)); + Push(ImGuiCol.HeaderHovered, new Vector4(0, 0, 0, 90)); + Push(ImGuiCol.HeaderActive, new Vector4(0, 0, 0, 120)); + + Push(ImGuiCol.Separator, new Vector4(75, 75, 75, 121)); + Push(ImGuiCol.SeparatorHovered, UIColors.Get("LightlessPurple")); + Push(ImGuiCol.SeparatorActive, UIColors.Get("LightlessPurpleActive")); + + Push(ImGuiCol.ResizeGrip, new Vector4(0, 0, 0, 0)); + Push(ImGuiCol.ResizeGripHovered, new Vector4(0, 0, 0, 0)); + Push(ImGuiCol.ResizeGripActive, UIColors.Get("LightlessPurpleActive")); + + Push(ImGuiCol.Tab, new Vector4(40, 40, 40, 255)); + Push(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple")); + Push(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive")); + Push(ImGuiCol.TabUnfocused, new Vector4(40, 40, 40, 255)); + Push(ImGuiCol.TabUnfocusedActive, UIColors.Get("LightlessPurpleActive")); + + Push(ImGuiCol.DockingPreview, UIColors.Get("LightlessPurpleActive")); + Push(ImGuiCol.DockingEmptyBg, new Vector4(50, 50, 50, 255)); + + Push(ImGuiCol.PlotLines, new Vector4(150, 150, 150, 255)); + + Push(ImGuiCol.TableHeaderBg, new Vector4(48, 48, 48, 255)); + Push(ImGuiCol.TableBorderStrong, new Vector4(79, 79, 89, 255)); + Push(ImGuiCol.TableBorderLight, new Vector4(59, 59, 64, 255)); + Push(ImGuiCol.TableRowBg, new Vector4(0, 0, 0, 0)); + Push(ImGuiCol.TableRowBgAlt, new Vector4(255, 255, 255, 15)); + + Push(ImGuiCol.TextSelectedBg, new Vector4(98, 75, 224, 255)); + Push(ImGuiCol.DragDropTarget, new Vector4(98, 75, 224, 255)); + + Push(ImGuiCol.NavHighlight, new Vector4(98, 75, 224, 179)); + Push(ImGuiCol.NavWindowingDimBg, new Vector4(204, 204, 204, 51)); + Push(ImGuiCol.NavWindowingHighlight, new Vector4(204, 204, 204, 89)); + + PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(6, 6)); + PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(4, 3)); + PushStyleVar(ImGuiStyleVar.CellPadding, new Vector2(4, 4)); + PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4)); + PushStyleVar(ImGuiStyleVar.ItemInnerSpacing, new Vector2(4, 4)); + + PushStyleVar(ImGuiStyleVar.IndentSpacing, 21.0f); + PushStyleVar(ImGuiStyleVar.ScrollbarSize, 10.0f); + PushStyleVar(ImGuiStyleVar.GrabMinSize, 20.0f); + + PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1.5f); + PushStyleVar(ImGuiStyleVar.ChildBorderSize, 1.5f); + PushStyleVar(ImGuiStyleVar.PopupBorderSize, 1.5f); + PushStyleVar(ImGuiStyleVar.FrameBorderSize, 0f); + + PushStyleVar(ImGuiStyleVar.WindowRounding, 7f); + PushStyleVar(ImGuiStyleVar.ChildRounding, 4f); + PushStyleVar(ImGuiStyleVar.FrameRounding, 4f); + PushStyleVar(ImGuiStyleVar.PopupRounding, 4f); + PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 4f); + PushStyleVar(ImGuiStyleVar.GrabRounding, 4f); + PushStyleVar(ImGuiStyleVar.TabRounding, 4f); + } + + public static void PopStyle() + { + if (!_hasPushed) + return; + + if (_pushedStyleVarCount > 0) + ImGui.PopStyleVar(_pushedStyleVarCount); + if (_pushedColorCount > 0) + ImGui.PopStyleColor(_pushedColorCount); + + _hasPushed = false; + _pushedColorCount = 0; + _pushedStyleVarCount = 0; + } + + private static void Push(ImGuiCol col, Vector4 rgba) + { + if (rgba.X > 1f || rgba.Y > 1f || rgba.Z > 1f || rgba.W > 1f) + rgba /= 255f; + + ImGui.PushStyleColor(col, rgba); + _pushedColorCount++; + } + + private static void Push(ImGuiCol col, uint packedRgba) + { + ImGui.PushStyleColor(col, packedRgba); + _pushedColorCount++; + } + + private static void PushStyleVar(ImGuiStyleVar var, float value) + { + ImGui.PushStyleVar(var, value); + _pushedStyleVarCount++; + } + + private static void PushStyleVar(ImGuiStyleVar var, Vector2 value) + { + ImGui.PushStyleVar(var, value); + _pushedStyleVarCount++; + } + } +} diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs new file mode 100644 index 0000000..cd6983c --- /dev/null +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -0,0 +1,297 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Group; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using LightlessSync.Utils; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Logging; +using LightlessSync.API.Data.Extensions; +using System.Numerics; + +namespace LightlessSync.UI; + +public class SyncshellFinderUI : WindowMediatorSubscriberBase +{ + private readonly ApiController _apiController; + private readonly LightlessConfigService _configService; + private readonly BroadcastService _broadcastService; + private readonly UiSharedService _uiSharedService; + private readonly NameplateService _nameplateService; + + private readonly List _nearbySyncshells = new(); + private int _selectedNearbyIndex = -1; + + private GroupJoinDto? _joinDto; + private GroupJoinInfoDto? _joinInfo; + private DefaultPermissionsDto _ownPermissions = null!; + + public SyncshellFinderUI( + ILogger logger, + LightlessMediator mediator, + PerformanceCollectorService performanceCollectorService, + BroadcastService broadcastService, + LightlessConfigService configService, + UiSharedService uiShared, + ApiController apiController, + NameplateService nameplateService + ) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) + { + _broadcastService = broadcastService; + _uiSharedService = uiShared; + _configService = configService; + _apiController = apiController; + _nameplateService = nameplateService; + + IsOpen = false; + SizeConstraints = new() + { + MinimumSize = new(600, 400), + MaximumSize = new(600, 400) + }; + + Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync()); + } + + public override async void OnOpen() + { + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + await RefreshSyncshellsAsync(); + } + + protected override void DrawInternal() + { + _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("PairBlue")); + _uiSharedService.ColoredSeparator(UIColors.Get("PairBlue")); + + if (_nearbySyncshells.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); + + if (!_broadcastService.IsBroadcasting) + { + + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); + + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active."); + ImGuiHelpers.ScaledDummy(0.5f); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessYellow2")); + + if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) + { + Mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + } + + ImGui.PopStyleColor(); + ImGui.PopStyleVar(); + + return; + } + + return; + } + + if (ImGui.BeginTable("##NearbySyncshellsTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + { + ImGui.TableSetupColumn("Alias", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("GID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Join", ImGuiTableColumnFlags.WidthFixed, 80f * ImGuiHelpers.GlobalScale); + ImGui.TableHeadersRow(); + + for (int i = 0; i < _nearbySyncshells.Count; i++) + { + var shell = _nearbySyncshells[i]; + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(shell.Group.Alias ?? "(No Alias)"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(shell.Group.GID); + ImGui.TableNextColumn(); + + var label = $"Join##{shell.Group.GID}"; + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); + + if (ImGui.Button(label)) + { + _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); + + _ = Task.Run(async () => + { + try + { + var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( + shell.Group, + shell.Password, + shell.GroupUserPreferredPermissions + )).ConfigureAwait(false); + + if (info != null && info.Success) + { + _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); + _joinInfo = info; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + + _logger.LogInformation($"Fetched join info for {shell.Group.GID}"); + } + else + { + _logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Join failed for {shell.Group.GID}"); + } + }); + } + + + ImGui.PopStyleColor(3); + } + + ImGui.EndTable(); + } + + if (_joinDto != null && _joinInfo != null && _joinInfo.Success) + DrawConfirmation(); + } + + private void DrawConfirmation() + { + ImGui.Separator(); + ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextUnformatted("Suggested Syncshell Permissions:"); + + DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); + DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); + DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); + + ImGui.NewLine(); + ImGui.NewLine(); + + if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) + { + var finalPermissions = GroupUserPreferredPermissions.NoneSet; + finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); + finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); + finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); + + _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); + _joinDto = null; + _joinInfo = null; + } + } + + private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"- {label}"); + + ImGui.SameLine(150 * ImGuiHelpers.GlobalScale); + ImGui.TextUnformatted("Current:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!current); + + ImGui.SameLine(300 * ImGuiHelpers.GlobalScale); + ImGui.TextUnformatted("Suggested:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!suggested); + + ImGui.SameLine(450 * ImGuiHelpers.GlobalScale); + using var id = ImRaii.PushId(label); + if (current != suggested) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) + apply(suggested); + } + + ImGui.NewLine(); + } + + private async Task RefreshSyncshellsAsync() + { + var syncshellBroadcasts = _nameplateService.GetActiveSyncshellBroadcasts(); + + if (syncshellBroadcasts.Count == 0) + { + ClearSyncshells(); + return; + } + + List updatedList; + try + { + var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts); + updatedList = groups?.ToList() ?? new(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); + return; + } + + var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(); + var newGids = updatedList.Select(s => s.Group.GID).ToHashSet(); + + if (currentGids.SetEquals(newGids)) + return; + + var previousGid = GetSelectedGid(); + + _nearbySyncshells.Clear(); + _nearbySyncshells.AddRange(updatedList); + + if (previousGid != null) + { + var newIndex = _nearbySyncshells.FindIndex(s => s.Group.GID == previousGid); + if (newIndex >= 0) + { + _selectedNearbyIndex = newIndex; + return; + } + } + + ClearSelection(); + } + + private void ClearSyncshells() + { + if (_nearbySyncshells.Count == 0) + return; + + _nearbySyncshells.Clear(); + ClearSelection(); + } + + private void ClearSelection() + { + _selectedNearbyIndex = -1; + _joinDto = null; + _joinInfo = null; + } + + private string? GetSelectedGid() + { + if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) + return null; + + return _nearbySyncshells[_selectedNearbyIndex].Group.GID; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } +} diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 1d56167..8fceb33 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -9,10 +9,22 @@ namespace LightlessSync.UI private static readonly Dictionary DefaultHexColors = new(StringComparer.OrdinalIgnoreCase) { { "LightlessPurple", "#ad8af5" }, + { "LightlessPurpleActive", "#be9eff" }, + { "LightlessPurpleDefault", "#9375d1" }, + + { "ButtonDefault", "#323232" }, + { "LightlessBlue", "#a6c2ff" }, { "LightlessYellow", "#ffe97a" }, + { "LightlessYellow2", "#cfbd63" }, + { "LightlessGreen", "#7cd68a" }, { "PairBlue", "#88a2db" }, { "DimRed", "#d44444" }, + + { "LightlessAdminText", "#ffd663" }, + { "LightlessAdminGlow", "#b09343" }, + { "LightlessModeratorText", "#94ffda" }, + { "LightlessModeratorGlow", "#599c84" }, }; private static LightlessConfigService? _configService; diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index ffca698..9a45d15 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -491,6 +491,25 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ImGui.Dummy(new Vector2(0, thickness * ImGuiHelpers.GlobalScale)); } + public void RoundedSeparator(Vector4? color = null, float thickness = 2f, float indent = 0f, float rounding = 4f) + { + float scale = ImGuiHelpers.GlobalScale; + + var drawList = ImGui.GetWindowDrawList(); + var min = ImGui.GetCursorScreenPos(); + var contentWidth = ImGui.GetContentRegionAvail().X; + + min.X += indent * scale; + var max = new Vector2(min.X + (contentWidth - indent * 2f) * scale, min.Y + thickness * scale); + + var col = ImGui.GetColorU32(color ?? ImGuiColors.DalamudGrey); + + + drawList.AddRectFilled(min, max, col, rounding); + + ImGui.Dummy(new Vector2(0, thickness * scale)); + } + public void MediumText(string text, Vector4? color = null) { FontText(text, MediumFont, color); diff --git a/LightlessSync/Utils/AtkNodeHelpers.cs b/LightlessSync/Utils/AtkNodeHelpers.cs new file mode 100644 index 0000000..02ed007 --- /dev/null +++ b/LightlessSync/Utils/AtkNodeHelpers.cs @@ -0,0 +1,99 @@ +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace LightlessSync.Utils; + +internal static unsafe class AtkNodeHelpers +{ + internal const ushort DefaultTextNodeWidth = 200; + internal const ushort DefaultTextNodeHeight = 14; + + internal static AtkTextNode* CreateNewTextNode(AtkUnitBase* pAddon, uint nodeID) + { + if (pAddon == null) return null; + var pNewNode = CreateOrphanTextNode(nodeID); + if (pNewNode != null) AttachTextNode(pAddon, pNewNode); + return pNewNode; + } + + internal static void HideNode(AtkUnitBase* pAddon, uint nodeID) + { + var pNode = GetTextNodeByID(pAddon, nodeID); + if (pNode != null) ((AtkResNode*)pNode)->ToggleVisibility(false); + } + + internal static AtkTextNode* GetTextNodeByID(AtkUnitBase* pAddon, uint nodeID) + { + if (pAddon == null) return null; + for (var i = 0; i < pAddon->UldManager.NodeListCount; ++i) + { + if (pAddon->UldManager.NodeList[i] == null) continue; + if (pAddon->UldManager.NodeList[i]->NodeId == nodeID) + { + return (AtkTextNode*)pAddon->UldManager.NodeList[i]; + } + } + return null; + } + + internal static void AttachTextNode(AtkUnitBase* pAddon, AtkTextNode* pNode) + { + if (pAddon == null) return; + + if (pNode != null) + { + var lastNode = pAddon->RootNode; + if (lastNode->ChildNode != null) + { + lastNode = lastNode->ChildNode; + while (lastNode->PrevSiblingNode != null) + { + lastNode = lastNode->PrevSiblingNode; + } + + pNode->AtkResNode.NextSiblingNode = lastNode; + pNode->AtkResNode.ParentNode = pAddon->RootNode; + lastNode->PrevSiblingNode = (AtkResNode*)pNode; + } + else + { + lastNode->ChildNode = (AtkResNode*)pNode; + pNode->AtkResNode.ParentNode = lastNode; + } + + pAddon->UldManager.UpdateDrawNodeList(); + } + } + + internal static AtkTextNode* CreateOrphanTextNode(uint nodeID, TextFlags textFlags = TextFlags.Edge) + { + var pNewNode = (AtkTextNode*)IMemorySpace.GetUISpace()->Malloc((ulong)sizeof(AtkTextNode), 8); + if (pNewNode != null) + { + IMemorySpace.Memset(pNewNode, 0, (ulong)sizeof(AtkTextNode)); + pNewNode->Ctor(); + + pNewNode->AtkResNode.Type = NodeType.Text; + pNewNode->AtkResNode.NodeFlags = NodeFlags.AnchorLeft | NodeFlags.AnchorTop; + pNewNode->AtkResNode.DrawFlags = 0; + pNewNode->AtkResNode.SetPositionShort(0, 0); + pNewNode->AtkResNode.SetWidth(DefaultTextNodeWidth); + pNewNode->AtkResNode.SetHeight(DefaultTextNodeHeight); + + pNewNode->LineSpacing = 24; + pNewNode->CharSpacing = 1; + pNewNode->AlignmentFontType = (byte)AlignmentType.BottomLeft; + pNewNode->FontSize = 12; + pNewNode->TextFlags = textFlags; + + pNewNode->AtkResNode.NodeId = nodeID; + + pNewNode->AtkResNode.Color.A = 0xFF; + pNewNode->AtkResNode.Color.R = 0xFF; + pNewNode->AtkResNode.Color.G = 0xFF; + pNewNode->AtkResNode.Color.B = 0xFF; + } + + return pNewNode; + } +} \ No newline at end of file diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs new file mode 100644 index 0000000..837d13d --- /dev/null +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -0,0 +1,171 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiSeStringRenderer; +using Dalamud.Interface.Utility; +using System.Numerics; + +namespace LightlessSync.Utils; + +public static class SeStringUtils +{ + public static SeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor) + { + var b = new SeStringBuilder(); + + if (glowColor is Vector4 glow) + b.Add(new GlowPayload(glow)); + + if (textColor is Vector4 color) + b.Add(new ColorPayload(color)); + + b.AddText(text ?? string.Empty); + + if (textColor is not null) + b.Add(new ColorEndPayload()); + + if (glowColor is not null) + b.Add(new GlowEndPayload()); + + return b.Build(); + } + + public static SeString BuildPlain(string text) + { + var b = new SeStringBuilder(); + b.AddText(text ?? string.Empty); + return b.Build(); + } + + public static void RenderSeString(SeString seString, Vector2 position, ImFontPtr? font = null, ImDrawListPtr? drawList = null) + { + drawList ??= ImGui.GetWindowDrawList(); + + var drawParams = new SeStringDrawParams + { + Font = font ?? UiBuilder.MonoFont, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + TargetDrawList = drawList + }; + + ImGui.SetCursorScreenPos(position); + ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); + } + + public static Vector2 RenderSeStringWithHitbox(SeString seString, Vector2 position, ImFontPtr? font = null) + { + var drawList = ImGui.GetWindowDrawList(); + + var drawParams = new SeStringDrawParams + { + Font = font ?? UiBuilder.MonoFont, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + TargetDrawList = drawList + }; + + ImGui.SetCursorScreenPos(position); + ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); + + var textSize = ImGui.CalcTextSize(seString.TextValue); + + ImGui.SetCursorScreenPos(position); + ImGui.InvisibleButton($"##hitbox_{Guid.NewGuid()}", textSize); + + return textSize; + } + + public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null) + { + var drawList = ImGui.GetWindowDrawList(); + + var drawParams = new SeStringDrawParams + { + Font = font ?? UiBuilder.MonoFont, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + TargetDrawList = drawList + }; + + var iconMacro = $""; + var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); + + ImGui.SetCursorScreenPos(position); + ImGui.InvisibleButton($"##iconHitbox_{Guid.NewGuid()}", drawResult.Size); + + return drawResult.Size; + } + + #region Internal Payloads + + private abstract class AbstractColorPayload : Payload + { + protected byte Red { get; init; } + protected byte Green { get; init; } + protected byte Blue { get; init; } + + protected override byte[] EncodeImpl() + { + return new byte[] { 0x02, ChunkType, 0x05, 0xF6, Red, Green, Blue, 0x03 }; + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { } + + public override PayloadType Type => PayloadType.Unknown; + protected abstract byte ChunkType { get; } + } + + private abstract class AbstractColorEndPayload : Payload + { + protected override byte[] EncodeImpl() + { + return new byte[] { 0x02, ChunkType, 0x02, 0xEC, 0x03 }; + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { } + + public override PayloadType Type => PayloadType.Unknown; + protected abstract byte ChunkType { get; } + } + + private class ColorPayload : AbstractColorPayload + { + protected override byte ChunkType => 0x13; + + public ColorPayload(Vector3 color) + { + Red = Math.Max((byte)1, (byte)(color.X * 255f)); + Green = Math.Max((byte)1, (byte)(color.Y * 255f)); + Blue = Math.Max((byte)1, (byte)(color.Z * 255f)); + } + + public ColorPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } + } + + private class ColorEndPayload : AbstractColorEndPayload + { + protected override byte ChunkType => 0x13; + } + + private class GlowPayload : AbstractColorPayload + { + protected override byte ChunkType => 0x14; + + public GlowPayload(Vector3 color) + { + Red = Math.Max((byte)1, (byte)(color.X * 255f)); + Green = Math.Max((byte)1, (byte)(color.Y * 255f)); + Blue = Math.Max((byte)1, (byte)(color.Z * 255f)); + } + + public GlowPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } + } + + private class GlowEndPayload : AbstractColorEndPayload + { + protected override byte ChunkType => 0x14; + } + + #endregion +} diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index 5f860bf..95f1de8 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -1,5 +1,6 @@ using LightlessSync.API.Data; using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; @@ -34,6 +35,36 @@ public partial class ApiController await _lightlessHub!.SendAsync(nameof(UserAddPair), user).ConfigureAwait(false); } + public async Task TryPairWithContentId(string otherCid, string myCid) + { + if (!IsConnected) return; + await _lightlessHub!.SendAsync(nameof(TryPairWithContentId), otherCid, myCid).ConfigureAwait(false); + } + + public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null) + { + CheckConnection(); + await _lightlessHub!.InvokeAsync(nameof(SetBroadcastStatus), hashedCid, enabled, groupDto).ConfigureAwait(false); + } + + public async Task IsUserBroadcasting(string hashedCid) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync(nameof(IsUserBroadcasting), hashedCid).ConfigureAwait(false); + } + + public async Task AreUsersBroadcasting(List hashedCids) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync(nameof(AreUsersBroadcasting), hashedCids).ConfigureAwait(false); + } + + public async Task GetBroadcastTtl(string hashedCid) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync(nameof(GetBroadcastTtl), hashedCid).ConfigureAwait(false); + } + public async Task UserDelete() { CheckConnection(); diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs index 6b7c785..cd17ea8 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs @@ -1,4 +1,5 @@ using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.AspNetCore.SignalR.Client; @@ -80,6 +81,11 @@ public partial class ApiController CheckConnection(); return await _lightlessHub!.InvokeAsync(nameof(GroupJoinFinalize), passwordedGroup).ConfigureAwait(false); } + public async Task GroupJoinHashed(GroupJoinHashedDto dto) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync("GroupJoinHashed", dto).ConfigureAwait(false); + } public async Task GroupLeave(GroupDto group) { @@ -116,6 +122,18 @@ public partial class ApiController CheckConnection(); await _lightlessHub!.SendAsync(nameof(GroupUnbanUser), groupPair).ConfigureAwait(false); } + public async Task SetGroupBroadcastStatus(GroupBroadcastRequestDto dto) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync(nameof(SetGroupBroadcastStatus), dto).ConfigureAwait(false); + } + public async Task> GetBroadcastedGroups(List broadcastEntries) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync>(nameof(GetBroadcastedGroups), broadcastEntries) + .ConfigureAwait(false); + } + private void CheckConnection() { diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 8f569a6..7653529 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -100,6 +100,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL public string UID => _connectionDto?.User.UID ?? string.Empty; + public event Action? OnConnected; + public async Task CheckClientHealth() { return await _lightlessHub!.InvokeAsync(nameof(CheckClientHealth)).ConfigureAwait(false); @@ -230,6 +232,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL _connectionDto = await GetConnectionDto().ConfigureAwait(false); ServerState = ServerState.Connected; + OnConnected?.Invoke(); var currentClientVer = Assembly.GetExecutingAssembly().GetName().Version!; @@ -517,6 +520,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL return; } ServerState = ServerState.Connected; + OnConnected?.Invoke(); await LoadIninitialPairsAsync().ConfigureAwait(false); await LoadOnlinePairsAsync().ConfigureAwait(false); Mediator.Publish(new ConnectedMessage(_connectionDto));