diff --git a/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs new file mode 100644 index 0000000..cd5e57c --- /dev/null +++ b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs @@ -0,0 +1,11 @@ +namespace Lifestream.Enums; + +public enum ResidentialAetheryteKind +{ + None = -1, + Uldah = 9, + Gridania = 2, + Limsa = 8, + Foundation = 70, + Kugane = 111, +} \ No newline at end of file diff --git a/LightlessSync/Interop/InteropModel/GlobalModels.cs b/LightlessSync/Interop/InteropModel/GlobalModels.cs new file mode 100644 index 0000000..a02cbc6 --- /dev/null +++ b/LightlessSync/Interop/InteropModel/GlobalModels.cs @@ -0,0 +1 @@ +global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias); \ No newline at end of file diff --git a/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs new file mode 100644 index 0000000..0243e59 --- /dev/null +++ b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs @@ -0,0 +1,129 @@ +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Lifestream.Enums; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + + +namespace LightlessSync.Interop.Ipc; + +public sealed class IpcCallerLifestream : IpcServiceBase +{ + private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0)); + + private readonly ICallGateSubscriber _executeLifestreamCommand; + private readonly ICallGateSubscriber _isHere; + private readonly ICallGateSubscriber _goToHousingAddress; + private readonly ICallGateSubscriber _isBusy; + private readonly ICallGateSubscriber _abort; + private readonly ICallGateSubscriber _changeWorld; + private readonly ICallGateSubscriber _changeWorldById; + private readonly ICallGateSubscriber _aetheryteTeleport; + private readonly ICallGateSubscriber _aetheryteTeleportById; + private readonly ICallGateSubscriber _canChangeInstance; + private readonly ICallGateSubscriber _getCurrentInstance; + private readonly ICallGateSubscriber _getNumberOfInstances; + private readonly ICallGateSubscriber _changeInstance; + private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo; + + public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger logger) + : base(logger, lightlessMediator, pi, LifestreamDescriptor) + { + _executeLifestreamCommand = pi.GetIpcSubscriber("Lifestream.ExecuteCommand"); + _isHere = pi.GetIpcSubscriber("Lifestream.IsHere"); + _goToHousingAddress = pi.GetIpcSubscriber("Lifestream.GoToHousingAddress"); + _isBusy = pi.GetIpcSubscriber("Lifestream.IsBusy"); + _abort = pi.GetIpcSubscriber("Lifestream.Abort"); + _changeWorld = pi.GetIpcSubscriber("Lifestream.ChangeWorld"); + _changeWorldById = pi.GetIpcSubscriber("Lifestream.ChangeWorldById"); + _aetheryteTeleport = pi.GetIpcSubscriber("Lifestream.AetheryteTeleport"); + _aetheryteTeleportById = pi.GetIpcSubscriber("Lifestream.AetheryteTeleportById"); + _canChangeInstance = pi.GetIpcSubscriber("Lifestream.CanChangeInstance"); + _getCurrentInstance = pi.GetIpcSubscriber("Lifestream.GetCurrentInstance"); + _getNumberOfInstances = pi.GetIpcSubscriber("Lifestream.GetNumberOfInstances"); + _changeInstance = pi.GetIpcSubscriber("Lifestream.ChangeInstance"); + _getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo"); + CheckAPI(); + } + + public void ExecuteLifestreamCommand(string command) + { + if (!APIAvailable) return; + _executeLifestreamCommand.InvokeAction(command); + } + + public bool IsHere(AddressBookEntryTuple entry) + { + if (!APIAvailable) return false; + return _isHere.InvokeFunc(entry); + } + + public void GoToHousingAddress(AddressBookEntryTuple entry) + { + if (!APIAvailable) return; + _goToHousingAddress.InvokeAction(entry); + } + + public bool IsBusy() + { + if (!APIAvailable) return false; + return _isBusy.InvokeFunc(); + } + + public void Abort() + { + if (!APIAvailable) return; + _abort.InvokeAction(); + } + + public bool ChangeWorld(string worldName) + { + if (!APIAvailable) return false; + return _changeWorld.InvokeFunc(worldName); + } + + public bool AetheryteTeleport(string aetheryteName) + { + if (!APIAvailable) return false; + return _aetheryteTeleport.InvokeFunc(aetheryteName); + } + + public bool ChangeWorldById(uint worldId) + { + if (!APIAvailable) return false; + return _changeWorldById.InvokeFunc(worldId); + } + + public bool AetheryteTeleportById(uint aetheryteId) + { + if (!APIAvailable) return false; + return _aetheryteTeleportById.InvokeFunc(aetheryteId); + } + + public bool CanChangeInstance() + { + if (!APIAvailable) return false; + return _canChangeInstance.InvokeFunc(); + } + public int GetCurrentInstance() + { + if (!APIAvailable) return -1; + return _getCurrentInstance.InvokeFunc(); + } + public int GetNumberOfInstances() + { + if (!APIAvailable) return -1; + return _getNumberOfInstances.InvokeFunc(); + } + public void ChangeInstance(int instanceNumber) + { + if (!APIAvailable) return; + _changeInstance.InvokeAction(instanceNumber); + } + public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo() + { + if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1); + return _getCurrentPlotInfo.InvokeFunc(); + } +} \ No newline at end of file diff --git a/LightlessSync/Interop/Ipc/IpcManager.cs b/LightlessSync/Interop/Ipc/IpcManager.cs index 1131d7d..5e95413 100644 --- a/LightlessSync/Interop/Ipc/IpcManager.cs +++ b/LightlessSync/Interop/Ipc/IpcManager.cs @@ -9,7 +9,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase public IpcManager(ILogger logger, LightlessMediator mediator, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, - IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator) + IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio, + IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator) { CustomizePlus = customizeIpc; Heels = heelsIpc; @@ -19,6 +20,7 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Moodles = moodlesIpc; PetNames = ipcCallerPetNames; Brio = ipcCallerBrio; + Lifestream = ipcCallerLifestream; _wasInitialized = Initialized; if (_wasInitialized) @@ -47,8 +49,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase public IpcCallerPenumbra Penumbra { get; } public IpcCallerMoodles Moodles { get; } public IpcCallerPetNames PetNames { get; } - public IpcCallerBrio Brio { get; } + public IpcCallerLifestream Lifestream { get; } private void PeriodicApiStateCheck() { @@ -69,5 +71,6 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase } _wasInitialized = initialized; + Lifestream.CheckAPI(); } } \ No newline at end of file diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index f48e626..8f1a3de 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -157,6 +157,7 @@ public class LightlessConfig : ILightlessConfiguration public bool SyncshellFinderEnabled { get; set; } = false; public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; + public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; public bool AnimationAllowOneBasedShift { get; set; } = true; diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index b760070..88382c6 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -378,6 +378,11 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new IpcCallerLifestream( + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(sp => new IpcManager( sp.GetRequiredService>(), sp.GetRequiredService(), @@ -388,7 +393,9 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService() + )); // Notifications / HTTP services.AddSingleton(sp => new NotificationService( @@ -486,19 +493,11 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); - - services.AddScoped(sp => new SyncshellFinderUI( - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); @@ -584,7 +583,6 @@ public sealed class Plugin : IDalamudPlugin public void Dispose() { - _host.StopAsync().GetAwaiter().GetResult(); - _host.Dispose(); + _host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5)); } } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 39bcecf..e399a9d 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -931,6 +931,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null; } + public void TargetPlayerByAddress(nint address) + { + if (address == nint.Zero) return; + if (_clientState.IsPvP) return; + + _ = RunOnFrameworkThread(() => + { + var gameObject = CreateGameObject(address); + if (gameObject is null) return; + + var useFocusTarget = _configService.Current.UseFocusTarget; + if (useFocusTarget) + { + _targetManager.FocusTarget = gameObject; + } + else + { + _targetManager.Target = gameObject; + } + }); + } + private unsafe void CheckCharacterForDrawing(nint address, string characterName) { var gameObj = (GameObject*)address; diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index b13ff44..16de3c4 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -1,4 +1,4 @@ -using Dalamud.Plugin.Services; +using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; @@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private readonly HashSet _syncshellCids = []; private volatile bool _pendingLocalBroadcast; private TimeSpan? _pendingLocalTtl; + private string? _pendingLocalGid; private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); @@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private const int _maxQueueSize = 100; private volatile bool _batchRunning = false; + private volatile bool _disposed = false; public IReadOnlyDictionary BroadcastCache => _broadcastCache; public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); @@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase public void Update() { + if (_disposed) + return; + _frameCounter++; var lookupsThisFrame = 0; @@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private async Task BatchUpdateBroadcastCacheAsync(List cids) { + if (_disposed) + return; + var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false); + + if (_disposed) + return; + var now = DateTime.UtcNow; foreach (var (cid, info) in results) @@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase (_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID)); } + if (_disposed) + return; + var activeCids = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now) .Select(e => e.Key) @@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { + if (_disposed) + return; + if (!msg.Enabled) { _broadcastCache.Clear(); @@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _pendingLocalBroadcast = true; _pendingLocalTtl = msg.Ttl; + _pendingLocalGid = msg.Gid; TryPrimeLocalBroadcastCache(); } @@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase var expiry = DateTime.UtcNow + ttl; _broadcastCache.AddOrUpdate(localCid, - new BroadcastEntry(true, expiry, null), - (_, old) => new BroadcastEntry(true, expiry, old.GID)); + new BroadcastEntry(true, expiry, _pendingLocalGid), + (_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID)); _pendingLocalBroadcast = false; _pendingLocalTtl = null; + _pendingLocalGid = null; var now = DateTime.UtcNow; var activeCids = _broadcastCache @@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); + UpdateSyncshellBroadcasts(); } private void UpdateSyncshellBroadcasts() { + if (_disposed) + return; + var now = DateTime.UtcNow; var nearbyCids = GetNearbyHashedCids(out _); var newSet = nearbyCids.Count == 0 @@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase protected override void Dispose(bool disposing) { + _disposed = true; base.Dispose(disposing); _framework.Update -= OnFrameworkUpdate; - if (_cleanupTask != null) + + try { - _cleanupTask?.Wait(100, _cleanupCts.Token); + _cleanupCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Already disposed, can be ignored :) } - _cleanupCts.Cancel(); - _cleanupCts.Dispose(); + try + { + _cleanupTask?.Wait(100); + } + catch (Exception) + { + // Task may have already completed or been cancelled? + } - _cleanupTask?.Wait(100); - _cleanupCts.Dispose(); + try + { + _cleanupCts.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } } } diff --git a/LightlessSync/Services/LightFinder/LightFinderService.cs b/LightlessSync/Services/LightFinder/LightFinderService.cs index f07064c..b73dbed 100644 --- a/LightlessSync/Services/LightFinder/LightFinderService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderService.cs @@ -1,4 +1,4 @@ -using Dalamud.Interface; +using Dalamud.Interface; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; @@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber _waitingForTtlFetch = false; if (!wasEnabled || previousRemaining != validTtl) - _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl)); + { + var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null; + _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid)); + } _logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl); return true; diff --git a/LightlessSync/Services/Mediator/LightlessMediator.cs b/LightlessSync/Services/Mediator/LightlessMediator.cs index 52399e2..87e36ad 100644 --- a/LightlessSync/Services/Mediator/LightlessMediator.cs +++ b/LightlessSync/Services/Mediator/LightlessMediator.cs @@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService _ = Task.Run(async () => { - while (!_loopCts.Token.IsCancellationRequested) + try { - while (!_processQueue) + while (!_loopCts.Token.IsCancellationRequested) { + while (!_processQueue) + { + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + } + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + + HashSet processedMessages = []; + while (_messageQueue.TryDequeue(out var message)) + { + if (processedMessages.Contains(message)) { continue; } + + processedMessages.Add(message); + + ExecuteMessage(message); + } } - - await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); - - HashSet processedMessages = []; - while (_messageQueue.TryDequeue(out var message)) - { - if (processedMessages.Contains(message)) { continue; } - processedMessages.Add(message); - - ExecuteMessage(message); - } + } + catch (OperationCanceledException) + { + _logger.LogInformation("LightlessMediator stopped"); } }); diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 7e8e220..e6db9e7 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -124,7 +124,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : 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 BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase; public record UserLeftSyncshell(string gid) : MessageBase; public record UserJoinedSyncshell(string gid) : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase; diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 33ab3ae..cbc64f4 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -8,6 +8,7 @@ using LightlessSync.UI.Tags; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; namespace LightlessSync.Services; @@ -23,6 +24,7 @@ public class UiFactory private readonly PerformanceCollectorService _performanceCollectorService; private readonly ProfileTagService _profileTagService; private readonly DalamudUtilService _dalamudUtilService; + private readonly PairFactory _pairFactory; public UiFactory( ILoggerFactory loggerFactory, @@ -34,7 +36,8 @@ public class UiFactory LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, ProfileTagService profileTagService, - DalamudUtilService dalamudUtilService) + DalamudUtilService dalamudUtilService, + PairFactory pairFactory) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -46,6 +49,7 @@ public class UiFactory _performanceCollectorService = performanceCollectorService; _profileTagService = profileTagService; _dalamudUtilService = dalamudUtilService; + _pairFactory = pairFactory; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) @@ -58,7 +62,8 @@ public class UiFactory _pairUiService, dto, _performanceCollectorService, - _lightlessProfileManager); + _lightlessProfileManager, + _pairFactory); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index cabef7d..43f0c0b 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -34,8 +34,21 @@ namespace LightlessSync.UI; public class CompactUi : WindowMediatorSubscriberBase { - private readonly CharacterAnalyzer _characterAnalyzer; + #region Constants + + private const float ConnectButtonHighlightThickness = 14f; + + #endregion + + #region Services + private readonly ApiController _apiController; + private readonly CharacterAnalyzer _characterAnalyzer; + private readonly DalamudUtilService _dalamudUtilService; + private readonly DrawEntityFactory _drawEntityFactory; + private readonly FileUploadManager _fileTransferManager; + private readonly IpcManager _ipcManager; + private readonly LightFinderService _broadcastService; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; @@ -44,34 +57,46 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly FileUploadManager _fileTransferManager; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PairUiService _pairUiService; - private readonly SelectTagForPairUi _selectTagForPairUi; - private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; - private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; - private readonly RenameSyncshellTagUi _renameSyncshellTagUi; - private readonly SelectPairForTagUi _selectPairsForGroupUi; - private readonly RenamePairTagUi _renamePairTagUi; - private readonly IpcManager _ipcManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; - private readonly TopTabMenu _tabMenu; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; - private readonly LightFinderService _broadcastService; - private readonly DalamudUtilService _dalamudUtilService; - + + #endregion + + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly RenamePairTagUi _renamePairTagUi; + private readonly RenameSyncshellTagUi _renameSyncshellTagUi; + private readonly SelectPairForTagUi _selectPairsForGroupUi; + private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; + private readonly SelectTagForPairUi _selectTagForPairUi; + private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; + private readonly SeluneBrush _seluneBrush = new(); + private readonly TopTabMenu _tabMenu; + + #endregion + + #region State + + private readonly ConcurrentDictionary> _currentDownloads = new(); private List _drawFolders; + private Pair? _focusedPair; private Pair? _lastAddedUser; private string _lastAddedUserComment = string.Empty; private Vector2 _lastPosition = Vector2.One; private Vector2 _lastSize = Vector2.One; + private int _pendingFocusFrame = -1; + private Pair? _pendingFocusPair; private bool _showModalForUserAddition; private float _transferPartHeight; private bool _wasOpen; private float _windowContentWidth; - private readonly SeluneBrush _seluneBrush = new(); - private const float _connectButtonHighlightThickness = 14f; - private Pair? _focusedPair; - private Pair? _pendingFocusPair; - private int _pendingFocusFrame = -1; + + #endregion + + #region Constructor public CompactUi( ILogger logger, @@ -127,6 +152,11 @@ public class CompactUi : WindowMediatorSubscriberBase .Apply(); _drawFolders = [.. DrawFolders]; + + _animatedHeader.Height = 120f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 250f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; #if DEBUG string dev = "Dev Build"; @@ -150,9 +180,14 @@ public class CompactUi : WindowMediatorSubscriberBase _lightlessMediator = mediator; } + #endregion + + #region Lifecycle + public override void OnClose() { ForceReleaseFocus(); + _animatedHeader.ClearParticles(); base.OnClose(); } @@ -164,6 +199,13 @@ public class CompactUi : WindowMediatorSubscriberBase using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); _windowContentWidth = UiSharedService.GetWindowContentRegionWidth(); + + // Draw animated header background (just the gradient/particles, content drawn by existing methods) + var startCursorY = ImGui.GetCursorPosY(); + _animatedHeader.Draw(_windowContentWidth, (_, _) => { }); + // Reset cursor to draw content on top of the header background + ImGui.SetCursorPosY(startCursorY); + if (!_apiController.IsCurrentVersion) { var ver = _apiController.CurrentClientVersion; @@ -209,17 +251,11 @@ public class CompactUi : WindowMediatorSubscriberBase } using (ImRaii.PushId("header")) DrawUIDHeader(); - _uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f); - using (ImRaii.PushId("serverstatus")) - { - DrawServerStatus(); - } selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); var style = ImGui.GetStyle(); var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y; var gradientInset = 4f * ImGuiHelpers.GlobalScale; var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset); - ImGui.Separator(); if (_apiController.ServerState is ServerState.Connected) { @@ -227,7 +263,6 @@ public class CompactUi : WindowMediatorSubscriberBase using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); - ImGui.Separator(); var transfersTop = ImGui.GetCursorScreenPos().Y; var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); @@ -290,6 +325,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Content Drawing + private void DrawPairs() { float ySize = Math.Abs(_transferPartHeight) < 0.0001f @@ -308,95 +347,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.EndChild(); } - private void DrawServerStatus() - { - var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); - var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture); - var userSize = ImGui.CalcTextSize(userCount); - var textSize = ImGui.CalcTextSize("Users Online"); -#if DEBUG - string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}"; -#else - string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}"; -#endif - var shardTextSize = ImGui.CalcTextSize(shardConnection); - var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty; - - if (_apiController.ServerState is ServerState.Connected) - { - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2); - if (!printShard) ImGui.AlignTextToFramePadding(); - ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount); - ImGui.SameLine(); - if (!printShard) ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Users Online"); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server"); - } - - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y); - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2); - ImGui.TextUnformatted(shardConnection); - } - - ImGui.SameLine(); - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2); - } - bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; - var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); - var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; - - ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X); - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2); - } - - if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) - { - using (ImRaii.PushColor(ImGuiCol.Text, color)) - { - if (_uiSharedService.IconButton(connectedIcon)) - { - if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) - { - _serverManager.CurrentServer.FullPause = true; - _serverManager.Save(); - } - else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) - { - _serverManager.CurrentServer.FullPause = false; - _serverManager.Save(); - } - - _ = _apiController.CreateConnectionsAsync(); - } - } - - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight( - ImGui.GetItemRectMin(), - ImGui.GetItemRectMax(), - SeluneHighlightMode.Both, - borderOnly: true, - borderThicknessOverride: _connectButtonHighlightThickness, - exactSize: true, - clipToElement: true, - roundingOverride: ImGui.GetStyle().FrameRounding); - } - - UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); - } - } - private void DrawTransfers() { var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); @@ -492,11 +442,9 @@ public class CompactUi : WindowMediatorSubscriberBase return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); } - [StructLayout(LayoutKind.Auto)] - private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) - { - public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; - } + #endregion + + #region Header Drawing private void DrawUIDHeader() { @@ -532,21 +480,52 @@ public class CompactUi : WindowMediatorSubscriberBase using (_uiSharedService.IconFont.Push()) iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString()); - float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; - float uidStartX = (contentWidth - uidTextSize.X) / 2f; + float uidStartX = 25f; float cursorY = ImGui.GetCursorPosY(); + ImGui.SetCursorPosY(cursorY); + ImGui.SetCursorPosX(uidStartX); + + bool headerItemClicked; + using (_uiSharedService.UidFont.Push()) + { + if (useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); + var cursorPos = ImGui.GetCursorScreenPos(); + var targetFontSize = ImGui.GetFontSize(); + var font = ImGui.GetFont(); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); + } + else + { + ImGui.TextColored(uidColor, uidText); + } + } + + // Get the actual rendered text rect for proper icon alignment + var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin(); + var uidTextRectMin = ImGui.GetItemRectMin(); + var uidTextHovered = ImGui.IsItemHovered(); + headerItemClicked = ImGui.IsItemClicked(); + + // Track position for icons next to UID text + // Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height + float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f; + float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; + float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f; + var buttonSize = new Vector2(iconSize.X, uidTextSize.Y); + if (_configService.Current.BroadcastEnabled && _apiController.IsConnected) { - float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; - var buttonSize = new Vector2(iconSize.X, uidTextSize.Y); - - ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY)); + ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); + ImGui.InvisibleButton("BroadcastIcon", buttonSize); - var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); using (_uiSharedService.IconFont.Push()) - ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString()); + ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString()); + + nextIconX = ImGui.GetItemRectMax().X + 6f; if (ImGui.IsItemHovered()) @@ -618,50 +597,8 @@ public class CompactUi : WindowMediatorSubscriberBase if (ImGui.IsItemClicked()) _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } - - ImGui.SetCursorPosY(cursorY); - ImGui.SetCursorPosX(uidStartX); - - bool headerItemClicked; - using (_uiSharedService.UidFont.Push()) - { - if (useVanityColors) - { - var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); - var cursorPos = ImGui.GetCursorScreenPos(); - var targetFontSize = ImGui.GetFontSize(); - var font = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); - } - else - { - ImGui.TextColored(uidColor, uidText); - } - } - - if (ImGui.IsItemHovered()) - { - var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); - Selune.RegisterHighlight( - ImGui.GetItemRectMin() - padding, - ImGui.GetItemRectMax() + padding, - SeluneHighlightMode.Point, - exactSize: true, - clipToElement: true, - clipPadding: padding, - highlightColorOverride: vanityGlowColor, - highlightAlphaOverride: 0.05f); - } - - headerItemClicked = ImGui.IsItemClicked(); - - if (headerItemClicked) - { - ImGui.SetClipboardText(uidText); - } - - UiSharedService.AttachToolTip("Click to copy"); - + + // Warning threshold icon (next to lightfinder or UID text) if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) { var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); @@ -675,24 +612,30 @@ public class CompactUi : WindowMediatorSubscriberBase if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) { - ImGui.SameLine(); - ImGui.SetCursorPosY(cursorY + 15f); - _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); + ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); + + ImGui.InvisibleButton("WarningThresholdIcon", buttonSize); + var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); + using (_uiSharedService.IconFont.Push()) + ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString()); - string warningMessage = ""; - if (isOverTriHold) + if (ImGui.IsItemHovered()) { - warningMessage += $"You exceed your own triangles threshold by " + - $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; - warningMessage += Environment.NewLine; - + string warningMessage = ""; + if (isOverTriHold) + { + warningMessage += $"You exceed your own triangles threshold by " + + $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; + warningMessage += Environment.NewLine; + } + if (isOverVRAMUsage) + { + warningMessage += $"You exceed your own VRAM threshold by " + + $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; + } + UiSharedService.AttachToolTip(warningMessage); } - if (isOverVRAMUsage) - { - warningMessage += $"You exceed your own VRAM threshold by " + - $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; - } - UiSharedService.AttachToolTip(warningMessage); + if (ImGui.IsItemClicked()) { _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); @@ -701,6 +644,34 @@ public class CompactUi : WindowMediatorSubscriberBase } } + + if (uidTextHovered) + { + var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + uidTextRectMin - padding, + uidTextRectMin + uidTextRect + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: vanityGlowColor, + highlightAlphaOverride: 0.05f); + + ImGui.SetTooltip("Click to copy"); + } + + if (headerItemClicked) + { + ImGui.SetClipboardText(uidText); + } + + // Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout) + DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y); + + // Add spacing below the big UID + ImGuiHelpers.ScaledDummy(5f); + if (_apiController.ServerState is ServerState.Connected) { if (headerItemClicked) @@ -708,10 +679,12 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.SetClipboardText(_apiController.DisplayName); } - if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal)) + // Only show smaller UID line if DisplayName differs from UID (custom vanity name) + bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase); + + if (hasCustomName) { - var origTextSize = ImGui.CalcTextSize(_apiController.UID); - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2)); + ImGui.SetCursorPosX(uidStartX); if (useVanityColors) { @@ -746,14 +719,88 @@ public class CompactUi : WindowMediatorSubscriberBase { ImGui.SetClipboardText(_apiController.UID); } + + // Users Online on same line as smaller UID (with separator) + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("|"); + ImGui.SameLine(); + ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); + ImGui.SameLine(); + ImGui.TextUnformatted("Users Online"); + } + else + { + // No custom name - just show Users Online aligned to uidStartX + ImGui.SetCursorPosX(uidStartX); + ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); + ImGui.SameLine(); + ImGui.TextUnformatted("Users Online"); } } else { + ImGui.SetCursorPosX(uidStartX); UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor); } } + private void DrawConnectButton(float screenY, float textHeight) + { + var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); + bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; + var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); + var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; + + // Position on right side, vertically centered with text + if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) + { + var windowPos = ImGui.GetWindowPos(); + var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f; + var yOffset = (textHeight - buttonSize.Y) * 0.5f; + ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset)); + + using (ImRaii.PushColor(ImGuiCol.Text, color)) + using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)))) + { + if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y)) + { + if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) + { + _serverManager.CurrentServer.FullPause = true; + _serverManager.Save(); + } + else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) + { + _serverManager.CurrentServer.FullPause = false; + _serverManager.Save(); + } + + _ = _apiController.CreateConnectionsAsync(); + } + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight( + ImGui.GetItemRectMin(), + ImGui.GetItemRectMax(), + SeluneHighlightMode.Both, + borderOnly: true, + borderThicknessOverride: ConnectButtonHighlightThickness, + exactSize: true, + clipToElement: true, + roundingOverride: ImGui.GetStyle().FrameRounding); + } + + UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); + } + } + + #endregion + + #region Folder Building + private IEnumerable DrawFolders { get @@ -889,6 +936,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Filtering & Sorting + private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; @@ -1033,10 +1084,11 @@ public class CompactUi : WindowMediatorSubscriberBase return SortGroupEntries(entries, group); } - private void UiSharedService_GposeEnd() - { - IsOpen = _wasOpen; - } + #endregion + + #region GPose Handlers + + private void UiSharedService_GposeEnd() => IsOpen = _wasOpen; private void UiSharedService_GposeStart() { @@ -1044,6 +1096,10 @@ public class CompactUi : WindowMediatorSubscriberBase IsOpen = false; } + #endregion + + #region Focus Tracking + private void RegisterFocusCharacter(Pair pair) { _pendingFocusPair = pair; @@ -1089,4 +1145,16 @@ public class CompactUi : WindowMediatorSubscriberBase _pendingFocusPair = null; _pendingFocusFrame = -1; } + + #endregion + + #region Helper Types + + [StructLayout(LayoutKind.Auto)] + private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) + { + public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; + } + + #endregion } diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 0aecee3..cad3d0b 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -1,486 +1,1739 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; +using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; +using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using System.Numerics; -namespace LightlessSync.UI +namespace LightlessSync.UI; + +public class LightFinderUI : WindowMediatorSubscriberBase { - public class LightFinderUI : WindowMediatorSubscriberBase - { - private readonly ApiController _apiController; - private readonly LightlessConfigService _configService; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; - private readonly LightFinderPlateHandler _lightFinderPlateHandler; + #region Services - private IReadOnlyList _allSyncshells = Array.Empty(); - private string _userUid = string.Empty; + private readonly ActorObjectService _actorObjectService; + private readonly ApiController _apiController; + private readonly DalamudUtilService _dalamudUtilService; + private readonly LightFinderScannerService _broadcastScannerService; + private readonly LightFinderService _broadcastService; + private readonly LightlessConfigService _configService; + private readonly LightlessProfileManager _lightlessProfileManager; + private readonly LightFinderPlateHandler _lightFinderPlateHandler; + private readonly PairUiService _pairUiService; + private readonly UiSharedService _uiSharedService; - private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + #endregion - public LightFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - LightlessConfigService configService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly List _seResolvedSegments = new(); + private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + + #endregion + + #region State + + private IReadOnlyList _allSyncshells = Array.Empty(); + private bool _compactView; + private List _currentSyncshells = []; + private GroupJoinDto? _joinDto; + private GroupJoinInfoDto? _joinInfo; + private bool _joinModalOpen = true; + private readonly List _nearbySyncshells = []; + private DefaultPermissionsDto _ownPermissions = null!; + private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); + private int _selectedNearbyIndex = -1; + private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + private LightfinderTab _selectedTab = LightfinderTab.NearbySyncshells; + private string _userUid = string.Empty; + + private const float AnimationSpeed = 6f; + private readonly Dictionary _itemAlpha = new(StringComparer.Ordinal); + private readonly HashSet _currentVisibleItems = new(StringComparer.Ordinal); + private readonly HashSet _previousVisibleItems = new(StringComparer.Ordinal); + + private enum LightfinderTab { NearbySyncshells, NearbyPlayers, BroadcastSettings, Help } + +#if DEBUG + private enum LightfinderTabDebug { NearbySyncshells, NearbyPlayers, BroadcastSettings, Help, Debug } + private LightfinderTabDebug _selectedTabDebug = LightfinderTabDebug.NearbySyncshells; +#endif + +#if DEBUG + private bool _useTestSyncshells; +#endif + + #endregion + + #region Constructor + + public LightFinderUI( + ILogger logger, + LightlessMediator mediator, + PerformanceCollectorService performanceCollectorService, + LightFinderService broadcastService, + LightlessConfigService configService, + UiSharedService uiShared, + ApiController apiController, + LightFinderScannerService broadcastScannerService, + PairUiService pairUiService, + DalamudUtilService dalamudUtilService, + LightlessProfileManager lightlessProfileManager, + ActorObjectService actorObjectService , - LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) - { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _configService = configService; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; + LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) + { + _broadcastService = broadcastService; + _uiSharedService = uiShared; + _configService = configService; + _apiController = apiController; + _broadcastScannerService = broadcastScannerService; + _pairUiService = pairUiService; + _dalamudUtilService = dalamudUtilService; + _lightlessProfileManager = lightlessProfileManager; + _actorObjectService = actorObjectService; - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) - .Apply(); - _lightFinderPlateHandler = lightFinderPlateHandler; + _animatedHeader.Height = 85f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 90f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; + + IsOpen = false; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 2000)) + .Apply(); + + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + _lightFinderPlateHandler = lightFinderPlateHandler; + } + + #endregion + + #region Lifecycle + + public override void OnOpen() + { + _userUid = _apiController.UID; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + _ = RefreshSyncshellsAsync(); + _ = RefreshNearbySyncshellsAsync(); + } + + public override void OnClose() + { + _animatedHeader.ClearParticles(); + ClearSelection(); + base.OnClose(); + } + + #endregion + + #region Main Drawing + + protected override void DrawInternal() + { + var contentWidth = ImGui.GetContentRegionAvail().X; + _animatedHeader.Draw(contentWidth, (_, _) => { }); + + if (!_broadcastService.IsLightFinderAvailable) + { + ImGui.TextColored(UIColors.Get("LightlessYellow"), "This server doesn't support Lightfinder."); + ImGuiHelpers.ScaledDummy(2f); } - private void RebuildSyncshellDropdownOptions() + DrawStatusPanel(); + ImGuiHelpers.ScaledDummy(4f); + +#if DEBUG + var debugTabOptions = new List> { - var selectedGid = _configService.Current.SelectedFinderSyncshell; - var allSyncshells = _allSyncshells ?? []; - var filteredSyncshells = allSyncshells - .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) - .ToList(); + new("Nearby Syncshells", LightfinderTabDebug.NearbySyncshells), + new("Nearby Players", LightfinderTabDebug.NearbyPlayers), + new("Broadcast", LightfinderTabDebug.BroadcastSettings), + new("Help", LightfinderTabDebug.Help), + new("Debug", LightfinderTabDebug.Debug) + }; + UiSharedService.Tab("LightfinderTabs", debugTabOptions, ref _selectedTabDebug); - _syncshellOptions.Clear(); - _syncshellOptions.Add(("None", null, true)); + ImGuiHelpers.ScaledDummy(4f); - var addedGids = new HashSet(StringComparer.Ordinal); + switch (_selectedTabDebug) + { + case LightfinderTabDebug.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTabDebug.NearbyPlayers: + DrawNearbyPlayersTab(); + break; + case LightfinderTabDebug.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTabDebug.Help: + DrawHelpTab(); + break; + case LightfinderTabDebug.Debug: + DrawDebugTab(); + break; + } +#else + var tabOptions = new List> + { + new("Nearby Syncshells", LightfinderTab.NearbySyncshells), + new("Nearby Players", LightfinderTab.NearbyPlayers), + new("Broadcast", LightfinderTab.BroadcastSettings), + new("Help", LightfinderTab.Help) + }; + UiSharedService.Tab("LightfinderTabs", tabOptions, ref _selectedTab); + + ImGuiHelpers.ScaledDummy(4f); + + switch (_selectedTab) + { + case LightfinderTab.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTab.NearbyPlayers: + DrawNearbyPlayersTab(); + break; + case LightfinderTab.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTab.Help: + DrawHelpTab(); + break; + } +#endif - foreach (var shell in filteredSyncshells) + if (_joinDto != null && _joinInfo != null && _joinInfo.Success) + DrawJoinConfirmation(); + } + + private void DrawStatusPanel() + { + var scale = ImGuiHelpers.GlobalScale; + var isBroadcasting = _broadcastService.IsBroadcasting; + var cooldown = _broadcastService.RemainingCooldown; + var isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; + + var accent = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.32f); + var infoColor = ImGuiColors.DalamudGrey; + + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 46f * scale); + float buttonWidth = 130 * scale; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("StatusPanel", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) { - var label = shell.GroupAliasOrGID ?? shell.GID; - _syncshellOptions.Add((label, shell.GID, true)); - addedGids.Add(shell.GID); + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) + { + if (ImGui.BeginTable("StatusPanelTable", 6, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Time", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbySyncshells", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonWidth + 16f * scale); + + ImGui.TableNextRow(); + + // Status cell + var statusColor = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + var statusText = isBroadcasting ? "Active" : "Inactive"; + var statusIcon = isBroadcasting ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.TimesCircle; + DrawStatusCell(statusIcon, statusColor, statusText, "Status", infoColor, scale); + + // Time remaining Cooldown cell + string timeValue; + string timeSub; + Vector4 timeColor; + if (isOnCooldown) + { + timeValue = $"{Math.Ceiling(cooldown!.Value.TotalSeconds)}s"; + timeSub = "Cooldown"; + timeColor = UIColors.Get("DimRed"); + } + else if (isBroadcasting && _broadcastService.RemainingTtl is { } remaining && remaining > TimeSpan.Zero) + { + timeValue = $"{remaining:hh\\:mm\\:ss}"; + timeSub = "Time left"; + timeColor = UIColors.Get("LightlessYellow"); + } + else + { + timeValue = "--:--:--"; + timeSub = "Time left"; + timeColor = infoColor; + } + DrawStatusCell(FontAwesomeIcon.Clock, timeColor, timeValue, timeSub, infoColor, scale); + + // Nearby syncshells cell + var nearbySyncshellCount = _nearbySyncshells.Count; + var nearbySyncshellColor = nearbySyncshellCount > 0 ? UIColors.Get("LightlessPurple") : infoColor; + DrawStatusCell(FontAwesomeIcon.Compass, nearbySyncshellColor, nearbySyncshellCount.ToString(), "Syncshells", infoColor, scale); + + // Nearby players cell (exclude self) + string? myHashedCidForCount = null; + try { myHashedCidForCount = _dalamudUtilService.GetCID().ToString().GetHash256(); } catch { } + var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(myHashedCidForCount); + var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; + DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); + + // Broadcasting syncshell cell + var isBroadcastingSyncshell = _configService.Current.SyncshellFinderEnabled && isBroadcasting; + var broadcastSyncshellColor = isBroadcastingSyncshell ? UIColors.Get("LightlessGreen") : infoColor; + var broadcastSyncshellText = isBroadcastingSyncshell ? "Yes" : "No"; + var broadcastSyncshellIcon = FontAwesomeIcon.Wifi; + DrawStatusCell(broadcastSyncshellIcon, broadcastSyncshellColor, broadcastSyncshellText, "Broadcasting", infoColor, scale); + + // Enable/Disable button cell - right aligned + ImGui.TableNextColumn(); + + float cellWidth = ImGui.GetContentRegionAvail().X; + float offsetX = cellWidth - buttonWidth; + if (offsetX > 0) + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + { + Vector4 buttonColor; + if (isOnCooldown) + buttonColor = UIColors.Get("DimRed"); + else if (isBroadcasting) + buttonColor = UIColors.Get("LightlessGreen"); + else + buttonColor = UIColors.Get("LightlessPurple"); + + using (ImRaii.PushColor(ImGuiCol.Button, buttonColor)) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, buttonColor.WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, buttonColor.WithAlpha(0.75f))) + using (ImRaii.Disabled(isOnCooldown || !_broadcastService.IsLightFinderAvailable)) + { + string buttonText = isBroadcasting ? "Disable" : "Enable"; + if (ImGui.Button(buttonText, new Vector2(buttonWidth, 0))) + _broadcastService.ToggleBroadcast(); + } + } + + ImGui.EndTable(); + } + } + } + } + } + + private void DrawStatusCell(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, float scale) + { + ImGui.TableNextColumn(); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale))) + using (ImRaii.Group()) + { + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 6f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) + { + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, subColor)) + { + ImGui.TextUnformatted(subText); + } + } + } + + #endregion + + #region Animation Helpers + + private void UpdateItemAnimations(IEnumerable visibleItemIds) + { + var deltaTime = ImGui.GetIO().DeltaTime; + + _previousVisibleItems.Clear(); + foreach (var id in _currentVisibleItems) + _previousVisibleItems.Add(id); + + _currentVisibleItems.Clear(); + foreach (var id in visibleItemIds) + _currentVisibleItems.Add(id); + + // Fade in new items + foreach (var id in _currentVisibleItems) + { + if (!_itemAlpha.ContainsKey(id)) + _itemAlpha[id] = 0f; + + _itemAlpha[id] = Math.Min(1f, _itemAlpha[id] + deltaTime * AnimationSpeed); + } + + // Fade out removed items + var toRemove = new List(); + foreach (var (id, alpha) in _itemAlpha) + { + if (!_currentVisibleItems.Contains(id)) + { + _itemAlpha[id] = Math.Max(0f, alpha - deltaTime * AnimationSpeed); + if (_itemAlpha[id] <= 0.01f) + toRemove.Add(id); + } + } + + foreach (var id in toRemove) + _itemAlpha.Remove(id); + } + + private float GetItemAlpha(string itemId) + { + return _itemAlpha.TryGetValue(itemId, out var alpha) ? alpha : 1f; + } + + #endregion + + #region Nearby Syncshells Tab + + private void DrawNearbySyncshellsTab() + { + ImGui.BeginGroup(); + +#if DEBUG + if (ImGui.SmallButton("Test Data")) + { + _useTestSyncshells = !_useTestSyncshells; + _ = Task.Run(async () => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + } + ImGui.SameLine(); +#endif + + string checkboxLabel = "Compact"; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight() + 8f; + float availWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availWidth - checkboxWidth); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + + if (_nearbySyncshells.Count == 0) + { + DrawNoSyncshellsMessage(); + return; + } + + var cardData = BuildSyncshellCardData(); + if (cardData.Count == 0) + { + UpdateItemAnimations([]); + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells found."); + return; + } + + // Update animations for syncshell items + UpdateItemAnimations(cardData.Select(c => $"shell_{c.Shell.Group.GID}")); + + if (_compactView) + DrawSyncshellGrid(cardData); + else + DrawSyncshellList(cardData); + } + + private void DrawNoSyncshellsMessage() + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); + + if (!_broadcastService.IsBroadcasting) + { + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder must be active to find nearby syncshells."); + } + } + + private List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> BuildSyncshellCardData() + { + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch { } + + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList(); + + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>(); + + foreach (var shell in _nearbySyncshells) + { + if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) + continue; + +#if DEBUG + if (_useTestSyncshells) + { + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + cardData.Add((shell, $"{displayName} (Test World)", false)); + continue; + } +#endif + + var broadcast = broadcasts.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); + if (broadcast == null) + continue; + + var isOwnBroadcast = !string.IsNullOrEmpty(myHashedCid) && string.Equals(broadcast.HashedCID, myHashedCid, StringComparison.Ordinal); + + string broadcasterName; + if (isOwnBroadcast) + { + broadcasterName = "You"; + } + else + { + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); + if (string.IsNullOrEmpty(name)) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; } - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + cardData.Add((shell, broadcasterName, isOwnBroadcast)); + } + + return cardData; + } + + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> listData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + if (ImGui.BeginChild("SyncshellListScroll", new Vector2(-1, -1), border: false)) + { + foreach (var (shell, broadcasterName, isOwnBroadcast) in listData) { - var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); - if (matching != null) + DrawSyncshellListItem(shell, broadcasterName, isOwnBroadcast); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + } + ImGui.EndChild(); + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName, bool isOwnBroadcast) + { + var itemId = $"shell_{shell.Group.GID}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + + ImGui.PushID(shell.Group.GID); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); + float rowHeight = 74f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; + + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + if (isOwnBroadcast) + ImGui.TextColored(UIColors.Get("LightlessGreen"), broadcasterName); + else + ImGui.TextUnformatted(broadcasterName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(isOwnBroadcast ? "Your broadcast" : "Broadcaster"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); + IReadOnlyList groupTags = groupProfile?.Tags.Count > 0 + ? ProfileTagService.ResolveTags(groupProfile.Tags) + : []; + + var limitedTags = groupTags.Count > 3 ? [.. groupTags.Take(3)] : groupTags; + float tagScale = ImGuiHelpers.GlobalScale * 0.9f; + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + float tagsWidth = 0f; + + if (limitedTags.Count > 0) + (tagsWidth, _) = RenderProfileTagsSingleRow(limitedTags, tagScale); + else + { + ImGui.SetCursorPosX(startX); + ImGui.TextDisabled("No tags"); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + + float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); + ImGui.SetCursorPos(new Vector2(joinX, rowStartLocal.Y)); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> cardData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + foreach (var (shell, _, isOwnBroadcast) in cardData) + { + DrawSyncshellCompactItem(shell, isOwnBroadcast); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellCompactItem(GroupJoinDto shell, bool isOwnBroadcast) + { + var itemId = $"shell_{shell.Group.GID}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + + ImGui.PushID(shell.Group.GID); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); + float rowHeight = 36f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellCompact##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + if (isOwnBroadcast) + displayName += " (You)"; + var style = ImGui.GetStyle(); + float availW = ImGui.GetContentRegionAvail().X; + + ImGui.AlignTextToFramePadding(); + _uiSharedService.MediumText(displayName, isOwnBroadcast ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + ImGui.SameLine(); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawJoinButton(GroupJoinDto shell, bool fullWidth) + { + const string visibleLabel = "Join"; + var label = $"{visibleLabel}##{shell.Group.GID}"; + + var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.Group.GID, StringComparison.Ordinal)); + var isRecentlyJoined = _recentlyJoined.Contains(shell.Group.GID); + var isOwnBroadcast = _configService.Current.SyncshellFinderEnabled + && _broadcastService.IsBroadcasting + && string.Equals(_configService.Current.SelectedFinderSyncshell, shell.Group.GID, StringComparison.Ordinal); + + Vector2 buttonSize; + if (fullWidth) + { + buttonSize = new Vector2(-1, 0); + } + else + { + var textSize = ImGui.CalcTextSize(visibleLabel); + var width = textSize.X + ImGui.GetStyle().FramePadding.X * 20f; + buttonSize = new Vector2(width, 30f); + + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(curX + availX - buttonSize.X); + } + + if (isOwnBroadcast) + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("You can't join your own Syncshell..."); + } + else if (!isAlreadyMember && !isRecentlyJoined) + { + 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, buttonSize)) + { + _ = Task.Run(async () => { - var label = matching.GroupAliasOrGID ?? matching.GID; - _syncshellOptions.Add((label, matching.GID, true)); - addedGids.Add(matching.GID); + try + { + var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( + shell.Group, shell.Password, shell.GroupUserPreferredPermissions + )).ConfigureAwait(false); + + if (info?.Success == true) + { + _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); + _joinInfo = info; + _joinModalOpen = true; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Join failed for {GID}", shell.Group.GID); + } + }); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("Already a member of this Syncshell."); + } + + ImGui.PopStyleColor(3); + } + + + private void DrawJoinConfirmation() + { + if (_joinDto == null || _joinInfo == null) return; + + var scale = ImGuiHelpers.GlobalScale; + + // if not already open + if (!ImGui.IsPopupOpen("JoinSyncshellModal")) + ImGui.OpenPopup("JoinSyncshellModal"); + + Vector2 windowPos = ImGui.GetWindowPos(); + Vector2 windowSize = ImGui.GetWindowSize(); + float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale); + float modalHeight = 295f * scale; + ImGui.SetNextWindowPos(new Vector2( + windowPos.X + (windowSize.X - modalWidth) * 0.5f, + windowPos.Y + (windowSize.Y - modalHeight) * 0.5f + ), ImGuiCond.Always); + ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); + ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + + using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); + using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale); + using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale); + using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale)); + + ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar; + if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags)) + { + float contentWidth = ImGui.GetContentRegionAvail().X; + + // Header + _uiSharedService.MediumText("Join Syncshell", UIColors.Get("LightlessPurple")); + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextColored(ImGuiColors.DalamudGrey, $"{_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); + + ImGuiHelpers.ScaledDummy(8f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault").WithAlpha(0.4f)); + ImGuiHelpers.ScaledDummy(8f); + + // Permissions section + ImGui.TextColored(ImGuiColors.DalamudWhite, "Permissions"); + ImGuiHelpers.ScaledDummy(6f); + + DrawPermissionToggleRow("Sounds", FontAwesomeIcon.VolumeUp, + _joinInfo.GroupPermissions.IsPreferDisableSounds(), + _ownPermissions.DisableGroupSounds, + v => _ownPermissions.DisableGroupSounds = v, + contentWidth); + + DrawPermissionToggleRow("Animations", FontAwesomeIcon.Running, + _joinInfo.GroupPermissions.IsPreferDisableAnimations(), + _ownPermissions.DisableGroupAnimations, + v => _ownPermissions.DisableGroupAnimations = v, + contentWidth); + + DrawPermissionToggleRow("VFX", FontAwesomeIcon.Magic, + _joinInfo.GroupPermissions.IsPreferDisableVFX(), + _ownPermissions.DisableGroupVFX, + v => _ownPermissions.DisableGroupVFX = v, + contentWidth); + + ImGuiHelpers.ScaledDummy(12f); + + // Buttons + float buttonHeight = 32f * scale; + float buttonSpacing = 8f * scale; + float joinButtonWidth = (contentWidth - buttonSpacing) * 0.65f; + float cancelButtonWidth = (contentWidth - buttonSpacing) * 0.35f; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + { + // Join button + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f))) + { + if (ImGui.Button($"Join Syncshell##{_joinDto.Group.GID}", new Vector2(joinButtonWidth, buttonHeight))) + { + 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)); + _recentlyJoined.Add(_joinDto.Group.GID); + + _joinDto = null; + _joinInfo = null; + ImGui.CloseCurrentPopup(); + } + } + + ImGui.SameLine(0f, buttonSpacing); + + // Cancel button + using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0.3f, 0.3f, 0.3f, 1f))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, new Vector4(0.4f, 0.4f, 0.4f, 1f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, new Vector4(0.25f, 0.25f, 0.25f, 1f))) + { + if (ImGui.Button("Cancel", new Vector2(cancelButtonWidth, buttonHeight))) + { + _joinDto = null; + _joinInfo = null; + ImGui.CloseCurrentPopup(); + } } } - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + // Handle modal close via the bool ref + if (!_joinModalOpen) { - _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + _joinDto = null; + _joinInfo = null; + } + + ImGui.EndPopup(); + } + } + + private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action apply, float contentWidth) + { + var scale = ImGuiHelpers.GlobalScale; + float rowHeight = 28f * scale; + bool isDifferent = current != suggested; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 4f * scale)) + using (ImRaii.PushColor(ImGuiCol.ChildBg, new Vector4(0.18f, 0.15f, 0.22f, 0.6f))) + { + ImGui.BeginChild($"PermRow_{label}", new Vector2(contentWidth, rowHeight), false, ImGuiWindowFlags.NoScrollbar); + + float innerPadding = 8f * scale; + ImGui.SetCursorPos(new Vector2(innerPadding, (rowHeight - ImGui.GetTextLineHeight()) * 0.5f)); + + // Icon and label + var enabledColor = UIColors.Get("LightlessGreen"); + var disabledColor = UIColors.Get("DimRed"); + var currentColor = !current ? enabledColor : disabledColor; + + _uiSharedService.IconText(icon, currentColor); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextUnformatted(label); + + // Current status + ImGui.SameLine(); + float statusX = contentWidth * 0.38f; + ImGui.SetCursorPosX(statusX); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Current:"); + ImGui.SameLine(0f, 4f * scale); + _uiSharedService.BooleanToColoredIcon(!current, false); + + // Suggested status + ImGui.SameLine(); + float suggestedX = contentWidth * 0.60f; + ImGui.SetCursorPosX(suggestedX); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Suggested:"); + ImGui.SameLine(0f, 4f * scale); + _uiSharedService.BooleanToColoredIcon(!suggested, false); + + // Apply checkmark button if different + if (isDifferent) + { + ImGui.SameLine(); + float applyX = contentWidth - 26f * scale; + ImGui.SetCursorPosX(applyX); + ImGui.SetCursorPosY((rowHeight - ImGui.GetFrameHeight()) * 0.5f); + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen").WithAlpha(0.6f))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreenDefault"))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Check)) + apply(suggested); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Apply suggested"); + } + + ImGui.EndChild(); + } + ImGui.Dummy(new Vector2(0, 2f * scale)); + } + + + #endregion + + #region Nearby Players Tab + + private void DrawNearbyPlayersTab() + { + ImGui.BeginGroup(); + string checkboxLabel = "Compact"; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight() + 8f; + float availWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availWidth - checkboxWidth); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + + if (!_broadcastService.IsBroadcasting) + { + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder must be active to see nearby players."); + return; + } + + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch { } + + var activeBroadcasts = _broadcastScannerService.GetActiveBroadcasts(myHashedCid); + + if (activeBroadcasts.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby Lightfinder players found."); + return; + } + + var playerData = BuildNearbyPlayerData(activeBroadcasts); + if (playerData.Count == 0) + { + UpdateItemAnimations([]); + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby Lightfinder players found."); + return; + } + + // Update animations for player items + UpdateItemAnimations(playerData.Select(p => $"player_{p.HashedCid}")); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + if (ImGui.BeginChild("NearbyPlayersScroll", new Vector2(-1, -1), border: false)) + { + foreach (var data in playerData) + { + if (_compactView) + DrawNearbyPlayerCompactRow(data); + else + DrawNearbyPlayerRow(data); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + } + ImGui.EndChild(); + + ImGui.PopStyleVar(2); + } + + private List BuildNearbyPlayerData(List> activeBroadcasts) + { + var snapshot = _pairUiService.GetSnapshot(); + var playerData = new List(); + + foreach (var broadcast in activeBroadcasts) + { + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.Key); + if (string.IsNullOrEmpty(name) || address == nint.Zero) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => + p.IsVisible && + !string.IsNullOrEmpty(p.GetPlayerNameHash()) && + string.Equals(p.GetPlayerNameHash(), broadcast.Key, StringComparison.Ordinal)); + + var isDirectlyPaired = pair?.IsDirectlyPaired ?? false; + var sharedGroups = pair?.UserPair?.Groups ?? []; + var sharedGroupNames = sharedGroups + .Select(gid => snapshot.GroupsByGid.TryGetValue(gid, out var g) ? g.GroupAliasOrGID : gid) + .ToList(); + + playerData.Add(new NearbyPlayerData(broadcast.Key, name, worldName, address, pair, isDirectlyPaired, sharedGroupNames)); + } + + return playerData; + } + + private readonly record struct NearbyPlayerData( + string HashedCid, + string Name, + string? World, + nint Address, + Pair? Pair, + bool IsDirectlyPaired, + List SharedSyncshells); + + private void DrawNearbyPlayerRow(NearbyPlayerData data) + { + var itemId = $"player_{data.HashedCid}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + + ImGui.PushID(data.HashedCid); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); + float rowHeight = 74f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"PlayerRow##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); + + var serverName = !string.IsNullOrEmpty(data.World) ? data.World : "Unknown"; + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(serverName).X; + + _uiSharedService.MediumText(data.Name, UIColors.Get("LightlessPurple")); + if (data.Pair != null) + { + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair)); + } + + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + ImGui.TextUnformatted(serverName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Home World"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + + if (data.IsDirectlyPaired) + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + ImGui.TextColored(UIColors.Get("LightlessGreen"), "Direct Pair"); + } + else if (data.SharedSyncshells.Count > 0) + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + var shellText = data.SharedSyncshells.Count == 1 + ? data.SharedSyncshells[0] + : $"{data.SharedSyncshells.Count} shared shells"; + ImGui.TextColored(UIColors.Get("LightlessPurple"), shellText); + if (data.SharedSyncshells.Count > 1 && ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join("\n", data.SharedSyncshells)); + } + else + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder user"); + } + + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + + DrawPlayerActionButtons(data, startX, regionW, rowStartLocal.Y, style); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawNearbyPlayerCompactRow(NearbyPlayerData data) + { + var itemId = $"player_{data.HashedCid}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + + ImGui.PushID(data.HashedCid); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); + float rowHeight = 36f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"PlayerCompact##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); + + ImGui.AlignTextToFramePadding(); + + if (data.IsDirectlyPaired) + { + _uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + else if (data.SharedSyncshells.Count > 0) + { + _uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + + var displayText = !string.IsNullOrEmpty(data.World) ? $"{data.Name} ({data.World})" : data.Name; + _uiSharedService.MediumText(displayText, UIColors.Get("LightlessPurple")); + if (data.Pair != null) + { + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair)); + } + + ImGui.SameLine(); + DrawPlayerActionButtons(data, 0, ImGui.GetContentRegionAvail().X, ImGui.GetCursorPosY(), ImGui.GetStyle(), compact: true); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawPlayerActionButtons(NearbyPlayerData data, float startX, float regionW, float rowY, ImGuiStylePtr style, bool compact = false) + { + float buttonWidth = compact ? 60f * ImGuiHelpers.GlobalScale : 80f * ImGuiHelpers.GlobalScale; + float buttonHeight = compact ? 0 : 30f; + float totalButtonsWidth = buttonWidth * 2 + style.ItemSpacing.X; + + if (compact) + { + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(curX + availX - totalButtonsWidth); + } + else + { + ImGui.SetCursorPos(new Vector2(startX + regionW - totalButtonsWidth - style.ItemSpacing.X, rowY)); + } + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f))) + using (ImRaii.Disabled(data.IsDirectlyPaired)) + { + if (ImGui.Button($"Pair##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight))) + { + _ = SendPairRequestAsync(data.HashedCid); + } + } + if (data.IsDirectlyPaired) + UiSharedService.AttachToolTip("Already directly paired with this player."); + + ImGui.SameLine(); + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f))) + { + if (ImGui.Button($"Target##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight))) + { + TargetPlayerByAddress(data.Address); + } + } + } + + private async Task SendPairRequestAsync(string hashedCid) + { + if (string.IsNullOrWhiteSpace(hashedCid)) + return; + + try + { + await _apiController.TryPairWithContentId(hashedCid).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send pair request to {HashedCid}", hashedCid); + } + } + + private void TargetPlayerByAddress(nint address) + { + if (address == nint.Zero) + return; + + _dalamudUtilService.TargetPlayerByAddress(address); + } + + #endregion + + #region Broadcast Settings Tab + + private void DrawBroadcastSettingsTab() + { + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcast your Syncshell to nearby Lightfinder users. They can then join directly from the Nearby Syncshells tab."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + ImGuiHelpers.ScaledDummy(4f); + + bool isBroadcasting = _broadcastService.IsBroadcasting; + + if (isBroadcasting) + { + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Settings can only be changed while Lightfinder is disabled.", UIColors.Get("LightlessYellow"))); + ImGuiHelpers.ScaledDummy(4f); + } + + if (isBroadcasting) + ImGui.BeginDisabled(); + + bool shellFinderEnabled = _configService.Current.SyncshellFinderEnabled; + if (ImGui.Checkbox("Enable Syncshell Broadcasting", ref shellFinderEnabled)) + { + _configService.Current.SyncshellFinderEnabled = shellFinderEnabled; + _configService.Save(); + } + UiSharedService.AttachToolTip("When enabled and Lightfinder is active, your selected Syncshell will be visible to nearby users."); + + ImGuiHelpers.ScaledDummy(4f); + + ImGui.Text("Select Syncshell to broadcast:"); + + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); + var preview = currentOption.Label ?? "Select a Syncshell..."; + + ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##SyncshellDropdown", preview)) + { + foreach (var (label, gid, available) in _syncshellOptions) + { + bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); + + if (!available) + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + + if (ImGui.Selectable(label, isSelected)) + { + _configService.Current.SelectedFinderSyncshell = gid; + _configService.Save(); + _ = RefreshSyncshellsAsync(); + } + + if (!available && ImGui.IsItemHovered()) + ImGui.SetTooltip("This Syncshell is not available on the current service."); + + if (!available) + ImGui.PopStyleColor(); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + if (isBroadcasting) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Sync)) + _ = RefreshSyncshellsAsync(); + UiSharedService.AttachToolTip("Refresh Syncshell list"); + + ImGuiHelpers.ScaledDummy(8f); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Advanced Settings")) + Mediator.Publish(new OpenLightfinderSettingsMessage()); + ImGui.PopStyleVar(); + UiSharedService.AttachToolTip("Open Lightfinder settings in the Settings window."); + } + + #endregion + + #region Help Tab + + private void DrawHelpTab() + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4)); + + _uiSharedService.MediumText("What is Lightfinder?", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder lets other Lightless users know you use Lightless. While enabled, you and others can see each other via a nameplate label."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Pairing can be initiated via the right-click context menu on another player. The process requires mutual confirmation from both users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(2f); + + _uiSharedService.DrawNoteLine("", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(", the receiving user will get notified about pair requests.")); + + _uiSharedService.DrawNoteLine("", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(", pair requests will NOT be visible to the recipient.")); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Lightfinder is entirely "), + new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and does not share personal data with other users.")); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "All identifying information remains private to the server. Use Lightfinder when you're okay with being visible to other users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "You can broadcast a Syncshell you own or moderate to nearby Lightfinder users. Configure this in the Broadcast Settings tab."); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleVar(); + } + + #endregion + + private void DrawDebugTab() + { +#if DEBUG + if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen)) + { + var h = _lightFinderPlateHandler; + + var enabled = h.DebugEnabled; + if (ImGui.Checkbox("Enable LightFinder debug", ref enabled)) + h.DebugEnabled = enabled; + + if (h.DebugEnabled) + { + ImGui.Indent(); + + var disableOcc = h.DebugDisableOcclusion; + if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc)) + h.DebugDisableOcclusion = disableOcc; + + var drawUiRects = h.DebugDrawUiRects; + if (ImGui.Checkbox("Draw UI rects", ref drawUiRects)) + h.DebugDrawUiRects = drawUiRects; + + var drawLabelRects = h.DebugDrawLabelRects; + if (ImGui.Checkbox("Draw label rects", ref drawLabelRects)) + h.DebugDrawLabelRects = drawLabelRects; + + ImGui.Separator(); + ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}"); + ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}"); + ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}"); + ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}"); + + ImGui.Unindent(); + } + } + + ImGui.Separator(); + + ImGui.Text("Broadcast Cache"); + + if (ImGui.BeginTable("##BroadcastCacheTable", 4, + ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, + new Vector2(-1, 225f))) + { + 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 _broadcastScannerService.BroadcastCache) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(cid.Truncate(12)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(cid); + + 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(); + } +#endif + } + + #region Data Refresh + + private async Task RefreshSyncshellsAsync() + { + if (!_apiController.IsConnected) + { + _allSyncshells = []; + RebuildSyncshellDropdownOptions(); + return; + } + + try + { + _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch Syncshells."); + _allSyncshells = []; + } + + RebuildSyncshellDropdownOptions(); + } + + private void RebuildSyncshellDropdownOptions() + { + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var allSyncshells = _allSyncshells ?? []; + var filteredSyncshells = allSyncshells + .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) + .ToList(); + + _syncshellOptions.Clear(); + _syncshellOptions.Add(("None", null, true)); + + var addedGids = new HashSet(StringComparer.Ordinal); + + foreach (var shell in filteredSyncshells) + { + 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 => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); + if (matching != null) + { + var label = matching.GroupAliasOrGID ?? matching.GID; + _syncshellOptions.Add((label, matching.GID, true)); + addedGids.Add(matching.GID); } } - public Task RefreshSyncshells() - { - return RefreshSyncshellsInternal(); - } + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + } - private async Task RefreshSyncshellsInternal() + private async Task RefreshNearbySyncshellsAsync(string? gid = null) + { + var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); + var snapshot = _pairUiService.GetSnapshot(); + _currentSyncshells = [.. snapshot.GroupPairs.Keys]; + + _recentlyJoined.RemoveWhere(g => _currentSyncshells.Exists(s => string.Equals(s.GID, g, StringComparison.Ordinal))); + + List? updatedList = []; + +#if DEBUG + if (_useTestSyncshells) { - if (!_apiController.IsConnected) + updatedList = BuildTestSyncshells(); + } + else +#endif + { + if (syncshellBroadcasts.Count == 0) { - _allSyncshells = []; - RebuildSyncshellDropdownOptions(); + ClearSyncshells(); return; } try { - _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts).ConfigureAwait(false); + updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); } catch (Exception ex) { - _logger.LogError(ex, "Failed to fetch Syncshells."); - _allSyncshells = []; + _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); + return; } - - RebuildSyncshellDropdownOptions(); } - - public override void OnOpen() + if (updatedList == null || updatedList.Count == 0) { - _userUid = _apiController.UID; - _ = RefreshSyncshells(); + ClearSyncshells(); + return; } - protected override void DrawInternal() + if (gid != null && _recentlyJoined.Contains(gid)) + _recentlyJoined.Clear(); + + var previousGid = GetSelectedGid(); + + _nearbySyncshells.Clear(); + _nearbySyncshells.AddRange(updatedList); + + if (previousGid != null) { - if (!_broadcastService.IsLightFinderAvailable) + var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); + if (newIndex >= 0) { - _uiSharedService.MediumText("This server doesn't support Lightfinder.", UIColors.Get("LightlessYellow")); - - ImGuiHelpers.ScaledDummy(0.25f); + _selectedNearbyIndex = newIndex; + return; } + } - if (ImGui.BeginTabBar("##BroadcastTabs")) - { - if (ImGui.BeginTabItem("Lightfinder")) - { - _uiSharedService.MediumText("Lightfinder", UIColors.Get("PairBlue")); - - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, -2)); - - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users."); - - ImGui.Indent(15f); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.Text("- This is done using a 'Lightless' label above player nameplates."); - ImGui.PopStyleColor(); - ImGui.Unindent(15f); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Pairing may be initiated via the right-click context menu on another player." + - " The process requires mutual confirmation: the sender initiates the request, and the recipient completes it by responding with a request in return."); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will get notified about it.")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will "), - new SeStringUtils.RichTextEntry("NOT", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" get a notification, and the request will not be visible to them in any way.")); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Lightfinder is entirely "), - new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(" and does not share any data with other users. All identifying information remains private to the server.")); - - _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), "Pairing is intended as a mutual agreement between both parties. A pair request will not be visible to the recipient unless Lightfinder is enabled."); - - ImGuiHelpers.ScaledDummy(5f); - - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience."); - ImGui.PopStyleColor(); - - ImGui.PopStyleVar(); - - ImGuiHelpers.ScaledDummy(3f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - if (_configService.Current.BroadcastEnabled) - { - 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 || !_broadcastService.IsLightFinderAvailable) - ImGui.BeginDisabled(); - - string buttonText = isBroadcasting ? "Disable Lightfinder" : "Enable Lightfinder"; - - if (ImGui.Button(buttonText, new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - _broadcastService.ToggleBroadcast(); - } - - var toggleButtonHeight = ImGui.GetItemRectSize().Y; - - if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) - ImGui.EndDisabled(); - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - ImGui.SameLine(); - if (_uiSharedService.IconButton(FontAwesomeIcon.Cog, toggleButtonHeight)) - { - Mediator.Publish(new OpenLightfinderSettingsMessage()); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Open Lightfinder settings."); - ImGui.EndTooltip(); - } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Syncshell Finder")) - { - if (_allSyncshells == null) - { - ImGui.Text("Loading Syncshells..."); - _ = RefreshSyncshells(); - 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) - { - var warningColor = UIColors.Get("LightlessYellow"); - _uiSharedService.DrawNoteLine("! ", warningColor, - new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor)); - ImGuiHelpers.ScaledDummy(0.2f); - } - - 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 => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); - var preview = currentOption.Label ?? "Select a Syncshell..."; - - if (ImGui.BeginCombo("##SyncshellDropdown", preview)) - { - foreach (var (label, gid, available) in _syncshellOptions) - { - bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); - - if (!available) - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - - if (ImGui.Selectable(label, isSelected)) - { - _configService.Current.SelectedFinderSyncshell = gid; - _configService.Save(); - _ = RefreshSyncshells(); - } - - 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(); - } + ClearSelection(); + } #if DEBUG - if (ImGui.BeginTabItem("Debug")) - { - if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen)) - { - var h = _lightFinderPlateHandler; - - var enabled = h.DebugEnabled; - if (ImGui.Checkbox("Enable LightFinder debug", ref enabled)) - h.DebugEnabled = enabled; - - if (h.DebugEnabled) - { - ImGui.Indent(); - - var disableOcc = h.DebugDisableOcclusion; - if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc)) - h.DebugDisableOcclusion = disableOcc; - - var drawUiRects = h.DebugDrawUiRects; - if (ImGui.Checkbox("Draw UI rects", ref drawUiRects)) - h.DebugDrawUiRects = drawUiRects; - - var drawLabelRects = h.DebugDrawLabelRects; - if (ImGui.Checkbox("Draw label rects", ref drawLabelRects)) - h.DebugDrawLabelRects = drawLabelRects; - - ImGui.Separator(); - ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}"); - ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}"); - ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}"); - ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}"); - - ImGui.Unindent(); - } - } - - ImGui.Separator(); - - ImGui.Text("Broadcast Cache"); - - if (ImGui.BeginTable("##BroadcastCacheTable", 4, - ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, - new Vector2(-1, 225f))) - { - 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 _broadcastScannerService.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(); - } + private static List BuildTestSyncshells() + { + return + [ + new(new GroupData("TEST-ALPHA", "Alpha Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-BETA", "Beta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-GAMMA", "Gamma Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-DELTA", "Delta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-EPSILON", "Epsilon Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ZETA", "Zeta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ETA", "Eta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-THETA", "Theta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-IOTA", "Iota Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-KAPPA", "Kappa Shell"), "", GroupUserPreferredPermissions.NoneSet), + ]; + } #endif - ImGui.EndTabBar(); + 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; + } + + #endregion + + #region Helpers + + private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) + { + if (tags == null || tags.Count == 0) + return (0f, 0f); + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var baseLocal = ImGui.GetCursorPos(); + var baseScreen = ImGui.GetCursorScreenPos(); + float availableWidth = ImGui.GetContentRegionAvail().X; + if (availableWidth <= 0f) + availableWidth = 1f; + + float cursorLocalX = baseLocal.X; + float cursorScreenX = baseScreen.X; + float rowHeight = 0f; + + for (int i = 0; i < tags.Count; i++) + { + var tag = tags[i]; + if (!tag.HasContent) + continue; + + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + float tagWidth = tagSize.X; + float tagHeight = tagSize.Y; + + if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) + break; + + var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); + ImGui.SetCursorScreenPos(tagScreenPos); + ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); + + ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + cursorLocalX += tagWidth + style.ItemSpacing.X; + cursorScreenX += tagWidth + style.ItemSpacing.X; + rowHeight = MathF.Max(rowHeight, tagHeight); + } + + ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); + + float widthUsed = cursorLocalX - baseLocal.X; + return (widthUsed, rowHeight); + } + + private static string TruncateTextToWidth(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; + + if (maxWidth <= ellipsisWidth) + return ellipsis; + + int low = 0; + int high = text.Length; + string best = ellipsis; + + while (low <= high) + { + int mid = (low + high) / 2; + string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); + float width = ImGui.CalcTextSize(candidate).X; + + if (width <= maxWidth) + { + best = candidate; + low = mid + 1; + } + else + { + high = mid - 1; } } + + return best; } -} + + private IDalamudTextureWrap? GetIconWrap(uint iconId) + { + try + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve icon {IconId}", iconId); + } + + return null; + } + + #endregion +} \ No newline at end of file diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index c4d94ac..9c2f1ef 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -5,6 +5,7 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; +using Lifestream.Enums; using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; using LightlessSync.API.Data.Enum; @@ -71,6 +72,8 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly NameplateService _nameplateService; + private readonly AnimatedHeader _animatedHeader = new(); + private (int, int, FileCacheEntity) _currentProgress; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; @@ -208,7 +211,10 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateService = nameplateService; _actorObjectService = actorObjectService; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); - + _animatedHeader.Height = 120f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 250f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; WindowBuilder.For(this) .AllowPinning(true) .AllowClickthrough(false) @@ -244,6 +250,7 @@ public class SettingsUi : WindowMediatorSubscriberBase public override void OnClose() { + _animatedHeader.ClearParticles(); _uiShared.EditTrackerPosition = false; _uidToAddForIgnore = string.Empty; _secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate(); @@ -258,8 +265,8 @@ public class SettingsUi : WindowMediatorSubscriberBase protected override void DrawInternal() { + _animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { }); _ = _uiShared.DrawOtherPluginState(); - DrawSettingsContent(); } private static Vector3 PackedColorToVector3(uint color) @@ -1335,7 +1342,6 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TreePop(); } -#endif if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard")) { if (LastCreatedCharacterData != null) @@ -1351,6 +1357,39 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable) + { + _ipcManager.Lifestream.ExecuteLifestreamCommand("limsa"); + } + + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable) + { + var twintania = _dalamudUtilService.WorldData.Value + .FirstOrDefault(kvp => kvp.Value.Equals("Twintania", StringComparison.OrdinalIgnoreCase)); + + int ward = 29; + int plot = 7; + + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)twintania.Key, + City: (int)ResidentialAetheryteKind.Kugane, + Ward: ward, + PropertyType: 0, + Plot: plot, + Apartment: 1, + ApartmentSubdivision: false, + AliasEnabled: false, + Alias: "" + ); + + _logger.LogInformation("going to: {address}", addressEntry); + + _ipcManager.Lifestream.GoToHousingAddress(addressEntry); + } +#endif + + _uiShared.DrawCombo("Log Level", Enum.GetValues(), (l) => l.ToString(), (l) => { _configService.Current.LogLevel = l; @@ -2193,7 +2232,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); var openPopupOnAddition = _configService.Current.OpenPopupOnAdd; - + using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple"))) { if (popupTree.Visible) @@ -2250,11 +2289,20 @@ public class SettingsUi : WindowMediatorSubscriberBase var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible; var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; + var enableParticleEffects = _configService.Current.EnableParticleEffects; using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { if (behaviorTree.Visible) { + if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects)) + { + _configService.Current.EnableParticleEffects = enableParticleEffects; + _configService.Save(); + } + + _uiShared.DrawHelpText("This will enable particle effects in the UI."); + if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) { _configService.Current.EnableRightClickMenus = enableRightClickMenu; @@ -2963,16 +3011,21 @@ public class SettingsUi : WindowMediatorSubscriberBase var colorNames = new[] { - ("LightlessPurple", "Primary Purple", "Section titles and dividers"), - ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), - ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), - ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), - ("LightlessGreen", "Success Green", "Join buttons and success messages"), - ("LightlessYellow", "Warning Yellow", "Warning colors"), - ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), - ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), - ("DimRed", "Error Red", "Error and offline colors") - }; + ("LightlessPurple", "Primary Purple", "Section titles and dividers"), + ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), + ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), + ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), + ("LightlessGreen", "Success Green", "Join buttons and success messages"), + ("LightlessYellow", "Warning Yellow", "Warning colors"), + ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), + ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), + ("DimRed", "Error Red", "Error and offline colors"), + ("HeaderGradientTop", "Header Gradient (Top)", "Top color of the animated header background"), + ("HeaderGradientBottom", "Header Gradient (Bottom)", "Bottom color of the animated header background"), + ("HeaderStaticStar", "Header Stars", "Tint color for the static background stars in the header"), + ("HeaderShootingStar", "Header Shooting Star", "Tint color for the shooting star effect"), + }; + if (ImGui.BeginTable("##ColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) { diff --git a/LightlessSync/UI/Style/AnimatedHeader.cs b/LightlessSync/UI/Style/AnimatedHeader.cs index 15488ac..9df0847 100644 --- a/LightlessSync/UI/Style/AnimatedHeader.cs +++ b/LightlessSync/UI/Style/AnimatedHeader.cs @@ -43,10 +43,23 @@ public class AnimatedHeader private const float _extendedParticleHeight = 40f; public float Height { get; set; } = 150f; + + // Color keys for theming + public string? TopColorKey { get; set; } = "HeaderGradientTop"; + public string? BottomColorKey { get; set; } = "HeaderGradientBottom"; + public string? StaticStarColorKey { get; set; } = "HeaderStaticStar"; + public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar"; + + // Fallbacks if the color keys are not found public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f); public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f); + public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f); + public bool EnableParticles { get; set; } = true; public bool EnableBottomGradient { get; set; } = true; + + public float GradientHeight { get; set; } = 60f; /// /// Draws the animated header with some customizable content @@ -146,16 +159,21 @@ public class AnimatedHeader { var drawList = ImGui.GetWindowDrawList(); + var top = ResolveColor(TopColorKey, TopColor); + var bottom = ResolveColor(BottomColorKey, BottomColor); + drawList.AddRectFilledMultiColor( headerStart, headerEnd, - ImGui.GetColorU32(TopColor), - ImGui.GetColorU32(TopColor), - ImGui.GetColorU32(BottomColor), - ImGui.GetColorU32(BottomColor) + ImGui.GetColorU32(top), + ImGui.GetColorU32(top), + ImGui.GetColorU32(bottom), + ImGui.GetColorU32(bottom) ); // Draw static background stars + var starBase = ResolveColor(StaticStarColorKey, StaticStarColor); + var random = new Random(42); for (int i = 0; i < 50; i++) { @@ -164,23 +182,28 @@ public class AnimatedHeader (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) ); var brightness = 0.3f + (float)random.NextDouble() * 0.4f; - drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); + var starColor = starBase with { W = starBase.W * brightness }; + + drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor)); } } private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) { var drawList = ImGui.GetWindowDrawList(); - var gradientHeight = 60f; + var gradientHeight = GradientHeight; + var bottom = ResolveColor(BottomColorKey, BottomColor); for (int i = 0; i < gradientHeight; i++) { var progress = i / gradientHeight; var smoothProgress = progress * progress; - var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress; - var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress; - var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress; + + var r = bottom.X + (0.0f - bottom.X) * smoothProgress; + var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress; + var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress; var alpha = 1f - smoothProgress; + var gradientColor = new Vector4(r, g, b, alpha); drawList.AddLine( new Vector2(headerStart.X, headerEnd.Y + i), @@ -308,9 +331,11 @@ public class AnimatedHeader ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) : baseAlpha; + var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor); + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) { - var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); + var baseColor = shootingBase; for (int t = 1; t < particle.Trail.Count; t++) { @@ -319,17 +344,18 @@ public class AnimatedHeader var trailWidth = (1f - trailProgress) * 3f + 1f; var glowAlpha = trailAlpha * 0.4f; + drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = glowAlpha }), + ImGui.GetColorU32(baseColor with { W = glowAlpha }), trailWidth + 4f ); drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = trailAlpha }), + ImGui.GetColorU32(baseColor with { W = trailAlpha }), trailWidth ); } @@ -448,6 +474,13 @@ public class AnimatedHeader Hue = 270f }); } + private static Vector4 ResolveColor(string? key, Vector4 fallback) + { + if (string.IsNullOrWhiteSpace(key)) + return fallback; + + return UIColors.Get(key); + } /// /// Clears all active particles. Useful when closing or hiding a window with an animated header. diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index 3da7455..53dd682 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -40,9 +40,10 @@ internal static class MainStyle new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered), new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), - new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), - new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), - new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), + new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg), + new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), + new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), + new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 526b5ae..db380a0 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -4,9 +4,11 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -42,13 +44,32 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private Task? _pruneTask; private int _pruneDays = 14; + // Ban management fields + private Task>? _bannedUsersTask; + private bool _bannedUsersLoaded; + private string? _bannedUsersLoadError; + + private string _newBanUid = string.Empty; + private string _newBanReason = string.Empty; + private Task? _newBanTask; + private string? _newBanError; + private DateTime _newBanBusyUntilUtc; + + // Ban editing fields + private string? _editingBanUid; + private readonly Dictionary _banReasonEdits = new(StringComparer.Ordinal); + + private Task? _banEditTask; + private string? _banEditError; + private Task? _pruneSettingsTask; private bool _pruneSettingsLoaded; private bool _autoPruneEnabled; private int _autoPruneDays = 14; + private readonly PairFactory _pairFactory; public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager) + UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, PairFactory pairFactory) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; @@ -76,6 +97,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase MaximumSize = new(700, 2000), }; _pairUiService = pairUiService; + _pairFactory = pairFactory; } public GroupFullInfoDto GroupFullInfo { get; private set; } @@ -654,34 +676,345 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(3f); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server")) + EnsureBanListLoaded(); + + DrawNewBanEntryRow(); + + ImGuiHelpers.ScaledDummy(4f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist")) { - _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; + QueueBanListRefresh(force: true); } + ImGuiHelpers.ScaledDummy(2f); + if (!_bannedUsersLoaded) + { + UiSharedService.ColorTextWrapped("Loading banlist from server...", ImGuiColors.DalamudGrey); + return; + } + + if (!string.IsNullOrWhiteSpace(_bannedUsersLoadError)) + { + UiSharedService.ColorTextWrapped(_bannedUsersLoadError!, ImGuiColors.DalamudRed); + return; + } + ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true); var style = ImGui.GetStyle(); float fullW = ImGui.GetContentRegionAvail().X; + float scale = ImGuiHelpers.GlobalScale; + + float frame = ImGui.GetFrameHeight(); + float actionIcons = 3; + float colActions = actionIcons * frame + (actionIcons - 1) * style.ItemSpacing.X + 10f * scale; - float colIdentity = fullW * 0.45f; float colMeta = fullW * 0.35f; - float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f; - // Header + float colIdentity = fullW - colMeta - colActions - style.ItemSpacing.X * 2.0f; + + float minIdentity = fullW * 0.40f; + if (colIdentity < minIdentity) + { + colIdentity = minIdentity; + colMeta = fullW - colIdentity - colActions - style.ItemSpacing.X * 2.0f; + if (colMeta < 80f * scale) colMeta = 80f * scale; + } + DrawBannedListHeader(colIdentity, colMeta); int rowIndex = 0; foreach (var bannedUser in _bannedUsers.ToList()) { - // Each row DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions); } ImGui.EndChild(); } + private void DrawNewBanEntryRow() + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.TextUnformatted("Add new ban"); + ImGui.PopStyleColor(); + + UiSharedService.TextWrapped("Enter a UID (Not Alias!) and optional reason. (Hold CTRL to enable the ban button.)"); + + var style = ImGui.GetStyle(); + float fullW = ImGui.GetContentRegionAvail().X; + + float uidW = fullW * 0.35f; + float reasonW = fullW * 0.50f; + float btnW = fullW - uidW - reasonW - style.ItemSpacing.X * 2f; + + // UID + ImGui.SetNextItemWidth(uidW); + ImGui.InputTextWithHint("##newBanUid", "UID...", ref _newBanUid, 128); + + // Reason + ImGui.SameLine(0f, style.ItemSpacing.X); + ImGui.SetNextItemWidth(reasonW); + ImGui.InputTextWithHint("##newBanReason", "Reason (optional)...", ref _newBanReason, 256); + + // Ban button + ImGui.SameLine(0f, style.ItemSpacing.X); + + var trimmedUid = (_newBanUid ?? string.Empty).Trim(); + var now = DateTime.UtcNow; + bool taskRunning = _newBanTask != null && !_newBanTask.IsCompleted; + bool busyLatched = now < _newBanBusyUntilUtc; + bool busy = taskRunning || busyLatched; + + bool canBan = UiSharedService.CtrlPressed() + && !string.IsNullOrWhiteSpace(_newBanUid) + && !busy; + + using (ImRaii.Disabled(!canBan)) + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) + { + ImGui.SetNextItemWidth(btnW); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban")) + { + _newBanError = null; + + _newBanBusyUntilUtc = DateTime.UtcNow.AddMilliseconds(750); + + _newBanTask = SubmitNewBanByUidAsync(trimmedUid, _newBanReason); + } + } + UiSharedService.AttachToolTip("Hold CTRL to enable banning by UID."); + + if (busy) + { + UiSharedService.ColorTextWrapped("Banning user...", ImGuiColors.DalamudGrey); + } + + if (_newBanTask != null && _newBanTask.IsCompleted && DateTime.UtcNow >= _newBanBusyUntilUtc) + { + if (_newBanTask.IsFaulted) + { + var _ = _newBanTask.Exception; + _newBanError ??= "Ban failed (see log)."; + } + + QueueBanListRefresh(force: true); + _newBanTask = null; + } + } + + private async Task SubmitNewBanByUidAsync(string uidOrAlias, string reason) + { + try + { + await Task.Yield(); + + uidOrAlias = (uidOrAlias ?? string.Empty).Trim(); + reason = (reason ?? string.Empty).Trim(); + + if (string.IsNullOrWhiteSpace(uidOrAlias)) + { + _newBanError = "UID is empty."; + return; + } + + string targetUid = uidOrAlias; + string? typedAlias = null; + + var snap = _pairUiService.GetSnapshot(); + if (snap.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + { + var match = pairs.FirstOrDefault(p => + string.Equals(p.UserData.UID, uidOrAlias, StringComparison.Ordinal) || + string.Equals(p.UserData.AliasOrUID, uidOrAlias, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + targetUid = match.UserData.UID; + typedAlias = match.UserData.Alias; + } + else + { + typedAlias = null; + } + } + + var userData = new UserData(UID: targetUid, Alias: typedAlias); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), reason) + .ConfigureAwait(false); + + _newBanUid = string.Empty; + _newBanReason = string.Empty; + _newBanError = null; + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ban '{uidOrAlias}' in group {gid}", uidOrAlias, GroupFullInfo.Group.GID); + _newBanError = "Failed to ban user (see log)."; + } + } + + private async Task SaveBanReasonViaBanUserAsync(string uid) + { + try + { + if (!_banReasonEdits.TryGetValue(uid, out var newReason)) + newReason = string.Empty; + + newReason = (newReason ?? string.Empty).Trim(); + + var userData = new UserData(uid.Trim()); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), newReason) + .ConfigureAwait(false); + + _editingBanUid = null; + _banEditError = null; + + await Task.Delay(450).ConfigureAwait(false); + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to edit ban reason for {uid} in group {gid}", uid, GroupFullInfo.Group.GID); + _banEditError = "Failed to update reason (see log)."; + } + } + + private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + { + using var id = ImRaii.PushId("banRow_" + bannedUser.UID); + + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + if (rowIndex % 2 == 0) + { + var drawList = ImGui.GetWindowDrawList(); + var pMin = ImGui.GetCursorScreenPos(); + var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; + var pMax = new Vector2( + pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, + pMin.Y + rowHeight); + + var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); + drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + } + + ImGui.SetCursorPosX(x0); + ImGui.AlignTextToFramePadding(); + + string alias = bannedUser.UserAlias ?? string.Empty; + string line1 = string.IsNullOrEmpty(alias) + ? bannedUser.UID + : $"{alias} ({bannedUser.UID})"; + + ImGui.TextUnformatted(line1); + + var fullReason = bannedUser.Reason ?? string.Empty; + + if (string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal)) + { + _banReasonEdits.TryGetValue(bannedUser.UID, out var editReason); + editReason ??= StripAliasSuffix(fullReason); + + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.SetNextItemWidth(colIdentity); + ImGui.InputTextWithHint("##banReasonEdit", "Reason...", ref editReason, 255); + ImGui.PopStyleColor(); + + _banReasonEdits[bannedUser.UID] = editReason; + + if (!string.IsNullOrWhiteSpace(_banEditError)) + UiSharedService.ColorTextWrapped(_banEditError!, ImGuiColors.DalamudRed); + } + else + { + if (!string.IsNullOrWhiteSpace(fullReason)) + { + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + + ImGui.PushTextWrapPos(x0 + colIdentity); + UiSharedService.TextWrapped(fullReason); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleColor(); + } + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); + + var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.TextUnformatted(dateText); + ImGui.PopStyleColor(); + ImGui.SameLine(); + + float frame = ImGui.GetFrameHeight(); + float actionsX0 = x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f; + + ImGui.SameLine(); + ImGui.SetCursorPosX(actionsX0); + + bool isEditing = string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal); + int actionCount = 1 + (isEditing ? 2 : 1); + + float totalW = actionCount * frame + (actionCount - 1) * style.ItemSpacing.X; + float startX = actionsX0 + MathF.Max(0, colActions - totalW) - 36f; + ImGui.SetCursorPosX(startX); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Check)) + { + _apiController.GroupUnbanUser(bannedUser); + _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); + } + UiSharedService.AttachToolTip("Unban"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (!isEditing) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Edit)) + { + _banEditError = null; + _editingBanUid = bannedUser.UID; + _banReasonEdits[bannedUser.UID] = StripAliasSuffix(bannedUser.Reason ?? string.Empty); + } + UiSharedService.AttachToolTip("Edit reason"); + } + else + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Save)) + { + _banEditError = null; + _banEditTask = SaveBanReasonViaBanUserAsync(bannedUser.UID); + } + UiSharedService.AttachToolTip("Save"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Times)) + { + _banEditError = null; + _editingBanUid = null; + } + UiSharedService.AttachToolTip("Cancel"); + } + } + private void DrawInvites(GroupPermissions perm) { var inviteTab = ImRaii.TabItem("Invites"); @@ -902,7 +1235,9 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (buttonCount == 0) return; - float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X; + float totalWidth = _isOwner + ? buttonCount * frameH + buttonCount * style.ItemSpacing.X + 20f + : buttonCount * frameH + buttonCount * style.ItemSpacing.X; float curX = ImGui.GetCursorPosX(); float avail = ImGui.GetContentRegionAvail().X; @@ -1031,69 +1366,40 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f); } - private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + private void QueueBanListRefresh(bool force = false) { - using var id = ImRaii.PushId("banRow_" + bannedUser.UID); - - var style = ImGui.GetStyle(); - float x0 = ImGui.GetCursorPosX(); - - if (rowIndex % 2 == 0) + if (!force) { - var drawList = ImGui.GetWindowDrawList(); - var pMin = ImGui.GetCursorScreenPos(); - var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; - var pMax = new Vector2( - pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, - pMin.Y + rowHeight); - - var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); - drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + if (_bannedUsersTask != null && !_bannedUsersTask.IsCompleted) + return; } - ImGui.SetCursorPosX(x0); - ImGui.AlignTextToFramePadding(); + _bannedUsersLoaded = false; + _bannedUsersLoadError = null; - string alias = bannedUser.UserAlias ?? string.Empty; - string line1 = string.IsNullOrEmpty(alias) - ? bannedUser.UID - : $"{alias} ({bannedUser.UID})"; + _bannedUsersTask = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); + } - ImGui.TextUnformatted(line1); + private void EnsureBanListLoaded() + { + _bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); - var reason = bannedUser.Reason ?? string.Empty; - if (!string.IsNullOrWhiteSpace(reason)) + if (_bannedUsersLoaded || _bannedUsersTask == null) + return; + + if (!_bannedUsersTask.IsCompleted) + return; + + if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled) { - var reasonPos = new Vector2(x0, ImGui.GetCursorPosY()); - ImGui.SetCursorPos(reasonPos); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - UiSharedService.TextWrapped(reason); - ImGui.PopStyleColor(); + _bannedUsersLoadError = "Failed to load banlist from server."; + _bannedUsers = []; + _bannedUsersLoaded = true; + return; } - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); - - var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.TextUnformatted(dateText); - ImGui.PopStyleColor(); - - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) - { - _apiController.GroupUnbanUser(bannedUser); - _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); - } - - UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell"); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + _bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? []; + _bannedUsersLoaded = true; } private void SavePruneSettings() @@ -1116,6 +1422,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private static string StripAliasSuffix(string reason) + { + const string marker = " (Alias at time of ban:"; + var idx = reason.IndexOf(marker, StringComparison.Ordinal); + return idx >= 0 ? reason[..idx] : reason; + } + private static bool MatchesUserFilter(Pair pair, string filterLower) { var note = pair.GetNote() ?? string.Empty; @@ -1127,6 +1440,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase || alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase); } + public override void OnOpen() + { + base.OnOpen(); + QueueBanListRefresh(force: true); + } public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs deleted file mode 100644 index 7076537..0000000 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ /dev/null @@ -1,855 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Textures.TextureWraps; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Plugin.Services; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; -using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto; -using LightlessSync.API.Dto.Group; -using LightlessSync.Services; -using LightlessSync.Services.LightFinder; -using LightlessSync.Services.Mediator; -using LightlessSync.UI.Services; -using LightlessSync.UI.Tags; -using LightlessSync.Utils; -using LightlessSync.WebAPI; -using Microsoft.Extensions.Logging; -using System.Numerics; - -namespace LightlessSync.UI; - -public class SyncshellFinderUI : WindowMediatorSubscriberBase -{ - private readonly ApiController _apiController; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; - private readonly PairUiService _pairUiService; - private readonly DalamudUtilService _dalamudUtilService; - - private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); - private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); - - private readonly List _seResolvedSegments = new(); - private readonly List _nearbySyncshells = []; - private List _currentSyncshells = []; - private int _selectedNearbyIndex = -1; - private int _syncshellPageIndex = 0; - private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); - - private GroupJoinDto? _joinDto; - private GroupJoinInfoDto? _joinInfo; - private DefaultPermissionsDto _ownPermissions = null!; - private bool _useTestSyncshells = false; - - private bool _compactView = false; - private readonly LightlessProfileManager _lightlessProfileManager; - - public SyncshellFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService, - PairUiService pairUiService, - DalamudUtilService dalamudUtilService, - LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) - { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; - _pairUiService = pairUiService; - _dalamudUtilService = dalamudUtilService; - _lightlessProfileManager = lightlessProfileManager; - - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) - .Apply(); - - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - } - - public override async void OnOpen() - { - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; - await RefreshSyncshellsAsync().ConfigureAwait(false); - } - - protected override void DrawInternal() - { - ImGui.BeginGroup(); - _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); - -#if DEBUG - if (ImGui.SmallButton("Show test syncshells")) - { - _useTestSyncshells = !_useTestSyncshells; - _ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false)); - } - ImGui.SameLine(); -#endif - - string checkboxLabel = "Compact view"; - float availWidth = ImGui.GetContentRegionAvail().X; - float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); - - float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f; - ImGui.SetCursorPosX(rightX); - ImGui.Checkbox(checkboxLabel, ref _compactView); - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - 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("LightlessPurple")); - - if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); - } - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - return; - } - - return; - } - - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? []; - _broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid); - - var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>(); - - foreach (var shell in _nearbySyncshells) - { - string broadcasterName; - - if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) - continue; - - if (_useTestSyncshells) - { - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - broadcasterName = $"{displayName} (Tester of TestWorld)"; - } - else - { - var broadcast = broadcasts - .FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); - - if (broadcast == null) - continue; - - var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); - if (string.IsNullOrEmpty(name)) - continue; - - var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); - broadcasterName = !string.IsNullOrEmpty(worldName) - ? $"{name} ({worldName})" - : name; - - var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid) - && string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal); - - cardData.Add((shell, broadcasterName, isSelfBroadcast)); - continue; - } - - cardData.Add((shell, broadcasterName, false)); - } - - if (cardData.Count == 0) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); - return; - } - - if (_compactView) - { - DrawSyncshellGrid(cardData); - } - else - { - DrawSyncshellList(cardData); - } - - - if (_joinDto != null && _joinInfo != null && _joinInfo.Success) - DrawConfirmation(); - } - - private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData) - { - const int shellsPerPage = 3; - var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var (shell, broadcasterName, isSelfBroadcast) = listData[index]; - var broadcasterLabel = string.IsNullOrEmpty(broadcasterName) - ? (isSelfBroadcast ? "You" : string.Empty) - : (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName); - - ImGui.PushID(shell.Group.GID); - float rowHeight = 74f * ImGuiHelpers.GlobalScale; - - ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float regionW = ImGui.GetContentRegionAvail().X; - float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X; - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; - ImGui.SameLine(); - ImGui.SetCursorPosX(rightX); - ImGui.TextUnformatted(broadcasterLabel); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster of the syncshell."); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - var limitedTags = groupTags.Count > 3 - ? [.. groupTags.Take(3)] - : groupTags; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - Vector2 rowStartLocal = ImGui.GetCursorPos(); - - float tagsWidth = 0f; - float tagsHeight = 0f; - - if (limitedTags.Count > 0) - { - (tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - float btnBaselineY = rowStartLocal.Y; - float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); - - ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); - DrawJoinButton(shell, isSelfBroadcast); - - float btnHeight = ImGui.GetFrameHeightWithSpacing(); - float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); - - ImGui.SetCursorPos(new Vector2( - rowStartLocal.X, - rowStartLocal.Y + rowHeightUsed)); - - ImGui.EndChild(); - ImGui.PopID(); - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - } - - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData) - { - const int shellsPerPage = 4; - var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count); - - var avail = ImGui.GetContentRegionAvail(); - var spacing = ImGui.GetStyle().ItemSpacing; - - var cardWidth = (avail.X - spacing.X) / 2.0f; - var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f; - cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var localIndex = index - firstIndex; - var (shell, broadcasterName, isSelfBroadcast) = cardData[index]; - var broadcasterLabel = string.IsNullOrEmpty(broadcasterName) - ? (isSelfBroadcast ? "You" : string.Empty) - : (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName); - - if (localIndex % 2 != 0) - ImGui.SameLine(); - - ImGui.PushID(shell.Group.GID); - - ImGui.BeginGroup(); - _ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float availW = ImGui.GetContentRegionAvail().X; - - ImGui.BeginGroup(); - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float nameRightX = ImGui.GetItemRectMax().X; - - var regionMinScreen = ImGui.GetCursorScreenPos(); - float regionRightX = regionMinScreen.X + availW; - - float minBroadcasterX = nameRightX + style.ItemSpacing.X; - - float maxBroadcasterWidth = regionRightX - minBroadcasterX; - - string broadcasterToShow = broadcasterLabel; - - if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f) - { - float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X; - string toolTip; - - if (bcFullWidth > maxBroadcasterWidth) - { - broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth); - toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; - } - else - { - toolTip = "Broadcaster of the syncshell."; - } - - float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X; - - float broadX = regionRightX - bcWidth; - - broadX = MathF.Max(broadX, minBroadcasterX); - - ImGui.SameLine(); - var curPos = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale)); - ImGui.TextUnformatted(broadcasterToShow); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(toolTip); - } - - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - if (groupTags.Count > 0) - { - var limitedTags = groupTags.Count > 2 - ? [.. groupTags.Take(2)] - : groupTags; - - ImGui.SetCursorPosX(startX); - - var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - var buttonHeight = ImGui.GetFrameHeightWithSpacing(); - var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; - if (remainingY > 0) - ImGui.Dummy(new Vector2(0, remainingY)); - - DrawJoinButton(shell, isSelfBroadcast); - - ImGui.EndChild(); - ImGui.EndGroup(); - - ImGui.PopID(); - } - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawPagination(int totalPages) - { - if (totalPages > 1) - { - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var style = ImGui.GetStyle(); - string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}"; - - float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2; - float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2; - float textWidth = ImGui.CalcTextSize(pageLabel).X; - - float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2; - - float availWidth = ImGui.GetContentRegionAvail().X; - float offsetX = (availWidth - totalWidth) * 0.5f; - - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); - - if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0) - _syncshellPageIndex--; - - ImGui.SameLine(); - ImGui.Text(pageLabel); - - ImGui.SameLine(); - if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1) - _syncshellPageIndex++; - } - } - - private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast) - { - const string visibleLabel = "Join"; - var label = $"{visibleLabel}##{shell.Group.GID}"; - - var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); - var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); - - Vector2 buttonSize; - - if (!_compactView) - { - var style = ImGui.GetStyle(); - var textSize = ImGui.CalcTextSize(visibleLabel); - - var width = textSize.X + style.FramePadding.X * 20f; - buttonSize = new Vector2(width, 30f); - - float availX = ImGui.GetContentRegionAvail().X; - float curX = ImGui.GetCursorPosX(); - float newX = curX + (availX - buttonSize.X); - ImGui.SetCursorPosX(newX); - } - else - { - buttonSize = new Vector2(-1, 0); - } - - if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast) - { - 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, buttonSize)) - { - _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}"); - } - }); - } - } - else - { - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); - - using (ImRaii.Disabled()) - { - ImGui.Button(label, buttonSize); - } - - UiSharedService.AttachToolTip(isSelfBroadcast - ? "This is your own Syncshell." - : "Already a member or owner of this Syncshell."); - } - - ImGui.PopStyleColor(3); - } - - private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) - { - if (tags == null || tags.Count == 0) - return (0f, 0f); - - var drawList = ImGui.GetWindowDrawList(); - var style = ImGui.GetStyle(); - var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); - - var baseLocal = ImGui.GetCursorPos(); - var baseScreen = ImGui.GetCursorScreenPos(); - float availableWidth = ImGui.GetContentRegionAvail().X; - if (availableWidth <= 0f) - availableWidth = 1f; - - float cursorLocalX = baseLocal.X; - float cursorScreenX = baseScreen.X; - float rowHeight = 0f; - - for (int i = 0; i < tags.Count; i++) - { - var tag = tags[i]; - if (!tag.HasContent) - continue; - - var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - float tagWidth = tagSize.X; - float tagHeight = tagSize.Y; - - if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) - break; - - var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); - ImGui.SetCursorScreenPos(tagScreenPos); - ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); - - ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - cursorLocalX += tagWidth + style.ItemSpacing.X; - cursorScreenX += tagWidth + style.ItemSpacing.X; - rowHeight = MathF.Max(rowHeight, tagHeight); - } - - ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); - - float widthUsed = cursorLocalX - baseLocal.X; - return (widthUsed, rowHeight); - } - private static string TruncateTextToWidth(string text, float maxWidth) - { - if (string.IsNullOrEmpty(text)) - return text; - - const string ellipsis = "..."; - float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; - - if (maxWidth <= ellipsisWidth) - return ellipsis; - - int low = 0; - int high = text.Length; - string best = ellipsis; - - while (low <= high) - { - int mid = (low + high) / 2; - string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); - float width = ImGui.CalcTextSize(candidate).X; - - if (width <= maxWidth) - { - best = candidate; - low = mid + 1; - } - else - { - high = mid - 1; - } - } - - return best; - } - - private IDalamudTextureWrap? GetIconWrap(uint iconId) - { - try - { - if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) - return wrap; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId); - } - - return null; - } - - private void DrawConfirmation() - { - if (_joinDto != null && _joinInfo != null) - { - 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)); - - _recentlyJoined.Add(_joinDto.Group.GID); - - _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(string? gid = null) - { - var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); - var snapshot = _pairUiService.GetSnapshot(); - _currentSyncshells = [.. snapshot.GroupPairs.Keys]; - - _recentlyJoined.RemoveWhere(gid => - _currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); - - List? updatedList = []; - - if (_useTestSyncshells) - { - updatedList = BuildTestSyncshells(); - } - else - { - if (syncshellBroadcasts.Count == 0) - { - ClearSyncshells(); - return; - } - - try - { - var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts) - .ConfigureAwait(false); - updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); - return; - } - } - - if (updatedList == null || updatedList.Count == 0) - { - ClearSyncshells(); - return; - } - - if (gid != null && _recentlyJoined.Contains(gid)) - { - _recentlyJoined.Clear(); - } - - var previousGid = GetSelectedGid(); - - _nearbySyncshells.Clear(); - _nearbySyncshells.AddRange(updatedList); - - if (previousGid != null) - { - var newIndex = _nearbySyncshells.FindIndex(s => - string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - - if (newIndex >= 0) - { - _selectedNearbyIndex = newIndex; - return; - } - } - - ClearSelection(); - } - - private static List BuildTestSyncshells() - { - var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell"); - var testGroup2 = new GroupData("TEST-BETA", "Beta Shell"); - var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell"); - var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell"); - var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell"); - var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell"); - var testGroup7 = new GroupData("TEST-POINT", "Point Shell"); - var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell"); - - return - [ - new(testGroup1, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup2, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup3, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup4, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup5, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup6, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup7, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup8, "", GroupUserPreferredPermissions.NoneSet), - ]; - } - - private void ClearSyncshells() - { - if (_nearbySyncshells.Count == 0) - return; - - _nearbySyncshells.Clear(); - ClearSelection(); - } - - private void ClearSelection() - { - _selectedNearbyIndex = -1; - _syncshellPageIndex = 0; - _joinDto = null; - _joinInfo = null; - } - - private string? GetSelectedGid() - { - if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) - return null; - - return _nearbySyncshells[_selectedNearbyIndex].Group.GID; - } - -} diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 1297fa6..4760713 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -162,24 +162,32 @@ public class TopTabMenu ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) { - var x = ImGui.GetCursorScreenPos(); if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) { - TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; + _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) { Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); } - - ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Lightfinder) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); } - UiSharedService.AttachToolTip("Lightfinder"); + + var nearbyCount = GetNearbySyncshellCount(); + if (nearbyCount > 0) + { + var buttonMax = ImGui.GetItemRectMax(); + var badgeRadius = 8f * ImGuiHelpers.GlobalScale; + var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 1.3f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f); + var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString(); + var textSize = ImGui.CalcTextSize(badgeText); + + drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f))); + drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple"))); + + var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f); + drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText); + } + UiSharedService.AttachToolTip(nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"); ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -234,10 +242,7 @@ public class TopTabMenu DrawSyncshellMenu(availableWidth, spacing.X); DrawGlobalSyncshellButtons(availableWidth, spacing.X); } - else if (TabSelection == SelectedTab.Lightfinder) - { - DrawLightfinderMenu(availableWidth, spacing.X); - } + else if (TabSelection == SelectedTab.UserConfig) { DrawUserConfig(availableWidth, spacing.X); @@ -776,53 +781,22 @@ public class TopTabMenu } } } - - private void DrawLightfinderMenu(float availableWidth, float spacingX) - { - var buttonX = (availableWidth - (spacingX)) / 2f; - - var lightFinderLabel = GetLightfinderFinderLabel(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightFinderLabel, buttonX, center: true)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); - } - - ImGui.SameLine(); - - var syncshellFinderLabel = GetSyncshellFinderLabel(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI))); - } - } - - private string GetLightfinderFinderLabel() - { - string label = "Lightfinder"; - - if (_lightFinderService.IsBroadcasting) - { - var hashExclude = _dalamudUtilService.GetCID().ToString().GetHash256(); - var nearbyCount = _lightFinderScannerService.GetActiveBroadcasts(hashExclude).Count; - return $"{label} ({nearbyCount})"; - } - - return label; - } - - private string GetSyncshellFinderLabel() + + private int GetNearbySyncshellCount() { if (!_lightFinderService.IsBroadcasting) - return "Syncshell Finder"; + return 0; - var nearbyCount = _lightFinderScannerService - .GetActiveSyncshellBroadcasts(excludeLocal: true) - .Where(b => !string.IsNullOrEmpty(b.GID)) + var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); + + return _lightFinderScannerService + .GetActiveSyncshellBroadcasts() + .Where(b => + !string.IsNullOrEmpty(b.GID) && + !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) .Select(b => b.GID!) .Distinct(StringComparer.Ordinal) .Count(); - - return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder"; } private void DrawUserConfig(float availableWidth, float spacingX) diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 9d7f770..1decb77 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -6,7 +6,7 @@ namespace LightlessSync.UI { internal static class UIColors { - private static readonly Dictionary DefaultHexColors = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary _defaultHexColors = new(StringComparer.OrdinalIgnoreCase) { { "LightlessPurple", "#ad8af5" }, { "LightlessPurpleActive", "#be9eff" }, @@ -31,6 +31,12 @@ namespace LightlessSync.UI { "ProfileBodyGradientTop", "#2f283fff" }, { "ProfileBodyGradientBottom", "#372d4d00" }, + + { "HeaderGradientTop", "#140D26FF" }, + { "HeaderGradientBottom", "#1F1433FF" }, + + { "HeaderStaticStar", "#FFFFFFFF" }, + { "HeaderShootingStar", "#66CCFFFF" }, }; private static LightlessConfigService? _configService; @@ -45,7 +51,7 @@ namespace LightlessSync.UI if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true) return HexToRgba(customColorHex); - if (!DefaultHexColors.TryGetValue(name, out var hex)) + if (!_defaultHexColors.TryGetValue(name, out var hex)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); @@ -53,7 +59,7 @@ namespace LightlessSync.UI public static void Set(string name, Vector4 color) { - if (!DefaultHexColors.ContainsKey(name)) + if (!_defaultHexColors.ContainsKey(name)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); if (_configService != null) @@ -83,7 +89,7 @@ namespace LightlessSync.UI public static Vector4 GetDefault(string name) { - if (!DefaultHexColors.TryGetValue(name, out var hex)) + if (!_defaultHexColors.TryGetValue(name, out var hex)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); @@ -96,7 +102,7 @@ namespace LightlessSync.UI public static IEnumerable GetColorNames() { - return DefaultHexColors.Keys; + return _defaultHexColors.Keys; } public static Vector4 HexToRgba(string hexColor) diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index fc5225c..514f31e 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -79,6 +79,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase private readonly Dictionary _oauthTokenExpiry = []; private bool _penumbraExists = false; private bool _petNamesExists = false; + private bool _lifestreamExists = false; private int _serverSelectionIndex = -1; public UiSharedService(ILogger logger, IpcManager ipcManager, ApiController apiController, CacheMonitor cacheMonitor, FileDialogManager fileDialogManager, @@ -112,6 +113,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase _moodlesExists = _ipcManager.Moodles.APIAvailable; _petNamesExists = _ipcManager.PetNames.APIAvailable; _brioExists = _ipcManager.Brio.APIAvailable; + _lifestreamExists = _ipcManager.Lifestream.APIAvailable; }); UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => @@ -1105,6 +1107,10 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ColorText("Brio", GetBoolColor(_brioExists)); AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State)); + ImGui.SameLine(); + ColorText("Lifestream", GetBoolColor(_lifestreamExists)); + AttachToolTip(BuildPluginTooltip("Lifestream", _lifestreamExists, _ipcManager.Lifestream.State)); + if (!_penumbraExists || !_glamourerExists) { ImGui.TextColored(ImGuiColors.DalamudRed, "You need to install both Penumbra and Glamourer and keep them up to date to use Lightless Sync."); diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index e1b3ab3..d27e87c 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -40,6 +40,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase logger.LogInformation("UpdateNotesUi constructor called"); _uiShared = uiShared; _configService = configService; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; RespectCloseHotkey = true; ShowCloseButton = true; @@ -48,7 +49,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove; PositionCondition = ImGuiCond.Always; - + + WindowBuilder.For(this) .AllowPinning(false) .AllowClickthrough(false)