Files
LightlessClient/LightlessSync/UI/LightFinderUI.cs
2026-01-02 09:23:23 +01:00

1739 lines
68 KiB
C#

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 LightFinderPlateHandler _lightFinderPlateHandler;
private readonly PairUiService _pairUiService;
private readonly UiSharedService _uiSharedService;
#endregion
#region UI Components
private readonly AnimatedHeader _animatedHeader = new();
private readonly List<SeStringUtils.SeStringSegment> _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<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>();
private bool _compactView;
private List<GroupFullInfoDto> _currentSyncshells = [];
private GroupJoinDto? _joinDto;
private GroupJoinInfoDto? _joinInfo;
private bool _joinModalOpen = true;
private readonly List<GroupJoinDto> _nearbySyncshells = [];
private DefaultPermissionsDto _ownPermissions = null!;
private readonly HashSet<string> _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<string, float> _itemAlpha = new(StringComparer.Ordinal);
private readonly HashSet<string> _currentVisibleItems = new(StringComparer.Ordinal);
private readonly HashSet<string> _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<LightFinderUI> logger,
LightlessMediator mediator,
PerformanceCollectorService performanceCollectorService,
LightFinderService broadcastService,
LightlessConfigService configService,
UiSharedService uiShared,
ApiController apiController,
LightFinderScannerService broadcastScannerService,
PairUiService pairUiService,
DalamudUtilService dalamudUtilService,
LightlessProfileManager lightlessProfileManager,
ActorObjectService actorObjectService
,
LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
{
_broadcastService = broadcastService;
_uiSharedService = uiShared;
_configService = configService;
_apiController = apiController;
_broadcastScannerService = broadcastScannerService;
_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<SyncshellBroadcastsUpdatedMessage>(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<UserLeftSyncshell>(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false));
Mediator.Subscribe<UserJoinedSyncshell>(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false));
_lightFinderPlateHandler = lightFinderPlateHandler;
}
#endregion
#region Lifecycle
public override void OnOpen()
{
_userUid = _apiController.UID;
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
_ = RefreshSyncshellsAsync();
_ = RefreshNearbySyncshellsAsync();
}
public override void OnClose()
{
_animatedHeader.ClearParticles();
ClearSelection();
base.OnClose();
}
#endregion
#region Main Drawing
protected override void DrawInternal()
{
var contentWidth = ImGui.GetContentRegionAvail().X;
_animatedHeader.Draw(contentWidth, (_, _) => { });
if (!_broadcastService.IsLightFinderAvailable)
{
ImGui.TextColored(UIColors.Get("LightlessYellow"), "This server doesn't support Lightfinder.");
ImGuiHelpers.ScaledDummy(2f);
}
DrawStatusPanel();
ImGuiHelpers.ScaledDummy(4f);
#if DEBUG
var debugTabOptions = new List<UiSharedService.TabOption<LightfinderTabDebug>>
{
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<UiSharedService.TabOption<LightfinderTab>>
{
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<string> 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<string>();
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<ProfileTagDefinition> 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<bool> 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<NearbyPlayerData> BuildNearbyPlayerData(List<KeyValuePair<string, LightFinderScannerService.BroadcastEntry>> activeBroadcasts)
{
var snapshot = _pairUiService.GetSnapshot();
var playerData = new List<NearbyPlayerData>();
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<string> SharedSyncshells);
private void DrawNearbyPlayerRow(NearbyPlayerData data)
{
var itemId = $"player_{data.HashedCid}";
var alpha = GetItemAlpha(itemId);
if (alpha <= 0.01f)
return;
ImGui.PushID(data.HashedCid);
using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha);
float rowHeight = 74f * ImGuiHelpers.GlobalScale;
ImGui.BeginChild($"PlayerRow##{data.HashedCid}", new Vector2(-1, rowHeight), border: true);
var serverName = !string.IsNullOrEmpty(data.World) ? data.World : "Unknown";
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float regionW = ImGui.GetContentRegionAvail().X;
float rightTxtW = ImGui.CalcTextSize(serverName).X;
_uiSharedService.MediumText(data.Name, UIColors.Get("LightlessPurple"));
if (data.Pair != null)
{
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair));
}
float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X;
ImGui.SameLine();
ImGui.SetCursorPosX(rightX);
ImGui.TextUnformatted(serverName);
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Home World");
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
Vector2 rowStartLocal = ImGui.GetCursorPos();
if (data.IsDirectlyPaired)
{
ImGui.SetCursorPosX(startX);
_uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
ImGui.TextColored(UIColors.Get("LightlessGreen"), "Direct Pair");
}
else if (data.SharedSyncshells.Count > 0)
{
ImGui.SetCursorPosX(startX);
_uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
var shellText = data.SharedSyncshells.Count == 1
? data.SharedSyncshells[0]
: $"{data.SharedSyncshells.Count} shared shells";
ImGui.TextColored(UIColors.Get("LightlessPurple"), shellText);
if (data.SharedSyncshells.Count > 1 && ImGui.IsItemHovered())
ImGui.SetTooltip(string.Join("\n", data.SharedSyncshells));
}
else
{
ImGui.SetCursorPosX(startX);
_uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder user");
}
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
DrawPlayerActionButtons(data, startX, regionW, rowStartLocal.Y, style);
ImGui.EndChild();
ImGui.PopID();
}
private void DrawNearbyPlayerCompactRow(NearbyPlayerData data)
{
var itemId = $"player_{data.HashedCid}";
var alpha = GetItemAlpha(itemId);
if (alpha <= 0.01f)
return;
ImGui.PushID(data.HashedCid);
using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha);
float rowHeight = 36f * ImGuiHelpers.GlobalScale;
ImGui.BeginChild($"PlayerCompact##{data.HashedCid}", new Vector2(-1, rowHeight), border: true);
ImGui.AlignTextToFramePadding();
if (data.IsDirectlyPaired)
{
_uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
}
else if (data.SharedSyncshells.Count > 0)
{
_uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
}
else
{
_uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue"));
ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale);
}
var displayText = !string.IsNullOrEmpty(data.World) ? $"{data.Name} ({data.World})" : data.Name;
_uiSharedService.MediumText(displayText, UIColors.Get("LightlessPurple"));
if (data.Pair != null)
{
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair));
}
ImGui.SameLine();
DrawPlayerActionButtons(data, 0, ImGui.GetContentRegionAvail().X, ImGui.GetCursorPosY(), ImGui.GetStyle(), compact: true);
ImGui.EndChild();
ImGui.PopID();
}
private void DrawPlayerActionButtons(NearbyPlayerData data, float startX, float regionW, float rowY, ImGuiStylePtr style, bool compact = false)
{
float buttonWidth = compact ? 60f * ImGuiHelpers.GlobalScale : 80f * ImGuiHelpers.GlobalScale;
float buttonHeight = compact ? 0 : 30f;
float totalButtonsWidth = buttonWidth * 2 + style.ItemSpacing.X;
if (compact)
{
float availX = ImGui.GetContentRegionAvail().X;
float curX = ImGui.GetCursorPosX();
ImGui.SetCursorPosX(curX + availX - totalButtonsWidth);
}
else
{
ImGui.SetCursorPos(new Vector2(startX + regionW - totalButtonsWidth - style.ItemSpacing.X, rowY));
}
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)))
using (ImRaii.Disabled(data.IsDirectlyPaired))
{
if (ImGui.Button($"Pair##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight)))
{
_ = SendPairRequestAsync(data.HashedCid);
}
}
if (data.IsDirectlyPaired)
UiSharedService.AttachToolTip("Already directly paired with this player.");
ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f)))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f)))
{
if (ImGui.Button($"Target##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight)))
{
TargetPlayerByAddress(data.Address);
}
}
}
private async Task SendPairRequestAsync(string hashedCid)
{
if (string.IsNullOrWhiteSpace(hashedCid))
return;
try
{
await _apiController.TryPairWithContentId(hashedCid).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send pair request to {HashedCid}", hashedCid);
}
}
private void TargetPlayerByAddress(nint address)
{
if (address == nint.Zero)
return;
_dalamudUtilService.TargetPlayerByAddress(address);
}
#endregion
#region Broadcast Settings Tab
private void DrawBroadcastSettingsTab()
{
_uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue"));
ImGuiHelpers.ScaledDummy(2f);
ImGui.PushTextWrapPos();
ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcast your Syncshell to nearby Lightfinder users. They can then join directly from the Nearby Syncshells tab.");
ImGui.PopTextWrapPos();
ImGuiHelpers.ScaledDummy(4f);
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
ImGuiHelpers.ScaledDummy(4f);
bool isBroadcasting = _broadcastService.IsBroadcasting;
if (isBroadcasting)
{
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Settings can only be changed while Lightfinder is disabled.", UIColors.Get("LightlessYellow")));
ImGuiHelpers.ScaledDummy(4f);
}
if (isBroadcasting)
ImGui.BeginDisabled();
bool shellFinderEnabled = _configService.Current.SyncshellFinderEnabled;
if (ImGui.Checkbox("Enable Syncshell Broadcasting", ref shellFinderEnabled))
{
_configService.Current.SyncshellFinderEnabled = shellFinderEnabled;
_configService.Save();
}
UiSharedService.AttachToolTip("When enabled and Lightfinder is active, your selected Syncshell will be visible to nearby users.");
ImGuiHelpers.ScaledDummy(4f);
ImGui.Text("Select Syncshell to broadcast:");
var selectedGid = _configService.Current.SelectedFinderSyncshell;
var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal));
var preview = currentOption.Label ?? "Select a Syncshell...";
ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
if (ImGui.BeginCombo("##SyncshellDropdown", preview))
{
foreach (var (label, gid, available) in _syncshellOptions)
{
bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal);
if (!available)
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
if (ImGui.Selectable(label, isSelected))
{
_configService.Current.SelectedFinderSyncshell = gid;
_configService.Save();
_ = RefreshSyncshellsAsync();
}
if (!available && ImGui.IsItemHovered())
ImGui.SetTooltip("This Syncshell is not available on the current service.");
if (!available)
ImGui.PopStyleColor();
if (isSelected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
if (isBroadcasting)
ImGui.EndDisabled();
ImGui.SameLine();
if (_uiSharedService.IconButton(FontAwesomeIcon.Sync))
_ = RefreshSyncshellsAsync();
UiSharedService.AttachToolTip("Refresh Syncshell list");
ImGuiHelpers.ScaledDummy(8f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Advanced Settings"))
Mediator.Publish(new OpenLightfinderSettingsMessage());
ImGui.PopStyleVar();
UiSharedService.AttachToolTip("Open Lightfinder settings in the Settings window.");
}
#endregion
#region Help Tab
private void DrawHelpTab()
{
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4));
_uiSharedService.MediumText("What is Lightfinder?", UIColors.Get("PairBlue"));
ImGui.PushTextWrapPos();
ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder lets other Lightless users know you use Lightless. While enabled, you and others can see each other via a nameplate label.");
ImGui.PopTextWrapPos();
ImGuiHelpers.ScaledDummy(6f);
_uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue"));
ImGui.PushTextWrapPos();
ImGui.TextColored(ImGuiColors.DalamudGrey, "Pairing can be initiated via the right-click context menu on another player. The process requires mutual confirmation from both users.");
ImGui.PopTextWrapPos();
ImGuiHelpers.ScaledDummy(2f);
_uiSharedService.DrawNoteLine("", UIColors.Get("LightlessGreen"),
new SeStringUtils.RichTextEntry("If Lightfinder is "),
new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry(", the receiving user will get notified about pair requests."));
_uiSharedService.DrawNoteLine("", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("If Lightfinder is "),
new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(", pair requests will NOT be visible to the recipient."));
ImGuiHelpers.ScaledDummy(6f);
_uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue"));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Lightfinder is entirely "),
new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" and does not share personal data with other users."));
ImGui.PushTextWrapPos();
ImGui.TextColored(ImGuiColors.DalamudGrey, "All identifying information remains private to the server. Use Lightfinder when you're okay with being visible to other users.");
ImGui.PopTextWrapPos();
ImGuiHelpers.ScaledDummy(6f);
_uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue"));
ImGui.PushTextWrapPos();
ImGui.TextColored(ImGuiColors.DalamudGrey, "You can broadcast a Syncshell you own or moderate to nearby Lightfinder users. Configure this in the Broadcast Settings tab.");
ImGui.PopTextWrapPos();
ImGui.PopStyleVar();
}
#endregion
private void DrawDebugTab()
{
#if DEBUG
if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen))
{
var h = _lightFinderPlateHandler;
var enabled = h.DebugEnabled;
if (ImGui.Checkbox("Enable LightFinder debug", ref enabled))
h.DebugEnabled = enabled;
if (h.DebugEnabled)
{
ImGui.Indent();
var disableOcc = h.DebugDisableOcclusion;
if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc))
h.DebugDisableOcclusion = disableOcc;
var drawUiRects = h.DebugDrawUiRects;
if (ImGui.Checkbox("Draw UI rects", ref drawUiRects))
h.DebugDrawUiRects = drawUiRects;
var drawLabelRects = h.DebugDrawLabelRects;
if (ImGui.Checkbox("Draw label rects", ref drawLabelRects))
h.DebugDrawLabelRects = drawLabelRects;
ImGui.Separator();
ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}");
ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}");
ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}");
ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}");
ImGui.Unindent();
}
}
ImGui.Separator();
ImGui.Text("Broadcast Cache");
if (ImGui.BeginTable("##BroadcastCacheTable", 4,
ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY,
new Vector2(-1, 225f)))
{
ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableHeadersRow();
var now = DateTime.UtcNow;
foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
ImGui.TextUnformatted(cid.Truncate(12));
if (ImGui.IsItemHovered())
ImGui.SetTooltip(cid);
ImGui.TableNextColumn();
var colorBroadcast = entry.IsBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed");
ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast));
ImGui.TextUnformatted(entry.IsBroadcasting.ToString());
ImGui.TableNextColumn();
var remaining = entry.ExpiryTime - now;
var colorTtl = remaining <= TimeSpan.Zero ? UIColors.Get("DimRed")
: remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow")
: (Vector4?)null;
if (colorTtl != null)
ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value));
ImGui.TextUnformatted(remaining > TimeSpan.Zero ? remaining.ToString("hh\\:mm\\:ss") : "Expired");
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.GID ?? "-");
}
ImGui.EndTable();
}
#endif
}
#region Data Refresh
private async Task RefreshSyncshellsAsync()
{
if (!_apiController.IsConnected)
{
_allSyncshells = [];
RebuildSyncshellDropdownOptions();
return;
}
try
{
_allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch Syncshells.");
_allSyncshells = [];
}
RebuildSyncshellDropdownOptions();
}
private void RebuildSyncshellDropdownOptions()
{
var selectedGid = _configService.Current.SelectedFinderSyncshell;
var allSyncshells = _allSyncshells ?? [];
var filteredSyncshells = allSyncshells
.Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator())
.ToList();
_syncshellOptions.Clear();
_syncshellOptions.Add(("None", null, true));
var addedGids = new HashSet<string>(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<GroupJoinDto>? 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<GroupJoinDto> 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<ProfileTagDefinition> 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
}