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; public class LightFinderUI : WindowMediatorSubscriberBase { #region Services 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 PairUiService _pairUiService; private readonly UiSharedService _uiSharedService; #endregion #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 ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; _configService = configService; _apiController = apiController; _broadcastScannerService = broadcastScannerService; _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; _lightlessProfileManager = lightlessProfileManager; _actorObjectService = actorObjectService; _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)); } #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); } DrawStatusPanel(); ImGuiHelpers.ScaledDummy(4f); #if DEBUG var debugTabOptions = new List> { 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); ImGuiHelpers.ScaledDummy(4f); 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 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) { 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; } 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) { 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 () => { 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(); } } } // Handle modal close via the bool ref if (!_joinModalOpen) { _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 sir ma'am or whatever"); } 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 #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.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(); } } #endregion #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); } } if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); } 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) { updatedList = BuildTestSyncshells(); } else #endif { 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(); } #if DEBUG 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 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 }