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.Services; 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 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 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 enum LightfinderTab { NearbySyncshells, BroadcastSettings, Help } #if DEBUG private enum LightfinderTabDebug { NearbySyncshells, 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 ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; _configService = configService; _apiController = apiController; _broadcastScannerService = broadcastScannerService; _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; _lightlessProfileManager = lightlessProfileManager; _animatedHeader.Height = 100f; _animatedHeader.EnableBottomGradient = true; _animatedHeader.GradientHeight = 120f; _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; IsOpen = false; WindowBuilder.For(this) .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 600)) .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("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.BroadcastSettings: DrawBroadcastSettingsTab(); break; case LightfinderTabDebug.Help: DrawHelpTab(); break; case LightfinderTabDebug.Debug: DrawDebugTab(); break; } #else var tabOptions = new List> { new("Nearby Syncshells", LightfinderTab.NearbySyncshells), 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.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("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); ImGui.TableSetupColumn("NearbySyncshells", 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 players cell var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(); var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", 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); // 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 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) { ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells found."); return; } 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)> BuildSyncshellCardData() { string? myHashedCid = null; try { var cid = _dalamudUtilService.GetCID(); myHashedCid = cid.ToString().GetHash256(); } catch { } var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() .Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) .ToList(); var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); 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)")); continue; } #endif 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); var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; cardData.Add((shell, broadcasterName)); } return cardData; } private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> 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) in listData) { DrawSyncshellListItem(shell, broadcasterName); ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); } } ImGui.EndChild(); ImGui.PopStyleVar(2); } private void DrawSyncshellListItem(GroupJoinDto shell, string 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(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); ImGui.TextUnformatted(broadcasterName); if (ImGui.IsItemHovered()) ImGui.SetTooltip("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)> cardData) { ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); foreach (var (shell, _) in cardData) { DrawSyncshellCompactItem(shell); ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); } ImGui.PopStyleVar(2); } private void DrawSyncshellCompactItem(GroupJoinDto shell) { ImGui.PushID(shell.Group.GID); 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; var style = ImGui.GetStyle(); float availW = ImGui.GetContentRegionAvail().X; ImGui.AlignTextToFramePadding(); _uiSharedService.MediumText(displayName, 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, silly! That's like trying to high-five yourself."); } 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; _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; ImGui.Separator(); ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); ImGuiHelpers.ScaledDummy(2f); ImGui.TextUnformatted("Suggested 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(); if (_uiSharedService.IconTextButton(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; } ImGui.SameLine(); if (ImGui.Button("Cancel")) { _joinDto = null; _joinInfo = null; } } private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) { ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted($"- {label}"); ImGui.SameLine(120 * ImGuiHelpers.GlobalScale); ImGui.Text("Current:"); ImGui.SameLine(); _uiSharedService.BooleanToColoredIcon(!current); ImGui.SameLine(240 * ImGuiHelpers.GlobalScale); ImGui.Text("Suggested:"); ImGui.SameLine(); _uiSharedService.BooleanToColoredIcon(!suggested); ImGui.SameLine(380 * ImGuiHelpers.GlobalScale); using var id = ImRaii.PushId(label); if (current != suggested) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) apply(suggested); } ImGui.NewLine(); } #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 #region Debug Tab private void DrawDebugTab() { ImGui.Text("Broadcast Cache"); if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 200f))) { ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("Broadcasting", 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 }