using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; 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; using LightlessSync.API.Dto.Group; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; using System.Collections.Specialized; using System.Numerics; using System.Threading.Tasks; namespace LightlessSync.UI; public class SyncshellFinderUI : WindowMediatorSubscriberBase { private readonly ApiController _apiController; private readonly BroadcastService _broadcastService; private readonly UiSharedService _uiSharedService; private readonly BroadcastScannerService _broadcastScannerService; private readonly PairUiService _pairUiService; private readonly DalamudUtilService _dalamudUtilService; 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 const bool _useTestSyncshells = false; private bool _compactView = false; public SyncshellFinderUI( ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollectorService, BroadcastService broadcastService, UiSharedService uiShared, ApiController apiController, BroadcastScannerService broadcastScannerService, PairUiService pairUiService, DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; _apiController = apiController; _broadcastScannerService = broadcastScannerService; _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; IsOpen = false; SizeConstraints = new() { MinimumSize = new(600, 400), MaximumSize = new(600, 550) }; 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")); ImGui.SameLine(); 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(BroadcastUI))); } ImGui.PopStyleColor(); ImGui.PopStyleVar(); return; } return; } var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); foreach (var shell in _nearbySyncshells) { string broadcasterName; if (_useTestSyncshells) { var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; broadcasterName = $"Tester of {displayName}"; } 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; } cardData.Add((shell, broadcasterName)); } 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)> 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) = listData[index]; ImGui.PushID(shell.Group.GID); float rowHeight = 90f * 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")); float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; ImGui.SameLine(); ImGui.SetCursorPosX(rightX); ImGui.TextUnformatted(broadcasterName); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); DrawJoinButton(shell); 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)> 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) = cardData[index]; 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; _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcaster"); ImGui.TextUnformatted(broadcasterName); ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); var buttonHeight = ImGui.GetFrameHeightWithSpacing(); var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; if (remainingY > 0) ImGui.Dummy(new Vector2(0, remainingY)); DrawJoinButton(shell); 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("PairBlue")); 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(dynamic shell) { const string visibleLabel = "Join"; var label = $"{visibleLabel}##{shell.Group.GID}"; ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); 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, 0); 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) { 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 { using (ImRaii.Disabled()) { ImGui.Button(label, buttonSize); } UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); } ImGui.PopStyleColor(3); } 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(); _currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)]; _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?.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 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; } }