Compare commits

...

47 Commits

Author SHA1 Message Date
543ea6c865 Merge branch '2.0.3' into 2.0.0-crashing-bugfixes 2026-01-04 14:19:23 +00:00
cake
3bbda69699 Revert "Added another try on fetching download status"
This reverts commit deb7f67e59.
2026-01-03 23:22:18 +01:00
cake
deb7f67e59 Added another try on fetching download status 2026-01-03 23:12:18 +01:00
choco
9ba45670c5 top menu cleanup, removed duplicate old code 2026-01-03 02:08:28 +01:00
cake
f7bb73bcd1 Updated api 2026-01-02 18:34:07 +01:00
choco
4c07162ee3 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessAPI
2026-01-02 09:26:21 +01:00
choco
a4d62af73d lightfinder user text 2026-01-02 09:23:23 +01:00
choco
5fba3c01e7 lightfinder nearby badge alignment 2026-01-02 09:19:39 +01:00
choco
906dda3885 lightfinder nearby badge icon 2026-01-01 22:32:45 +01:00
choco
f812b6d09e own syncshell sometimes not showing in list bug 2026-01-01 22:32:34 +01:00
7e61954541 Location Sharing 2.0 (#125)
Need: Lightless-Sync/LightlessServer#49
Authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #125
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-31 17:31:31 +00:00
choco
89f59a98f5 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes 2025-12-31 09:02:55 +01:00
bbb3375661 2.0.3 staaato 2025-12-31 02:44:31 +00:00
choco
e95a2c3352 Merge remote-tracking branch 'refs/remotes/origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-30 19:32:42 +01:00
choco
a8340c3279 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
2025-12-30 14:55:42 +01:00
Tsubasahane
e25979e089 fix Icon direction 2025-12-30 18:04:54 +08:00
Tsubasahane
ca7375b9c3 dont check location when target is offline 2025-12-30 14:42:02 +08:00
Tsubasahane
f8752fcb4d changed kanmoji to show correctly 2025-12-30 14:37:13 +08:00
Tsubasahane
d1c955c74f Reuse WorldData and make context menu work for non-Global uses 2025-12-30 14:23:37 +08:00
Tsubasahane
91e60694ad triggers update when map changes 2025-12-30 11:20:12 +08:00
Tsubasahane
f37fdefddd show icon correctly 2025-12-29 16:43:12 +08:00
Tsubasahane
18fa0a47b1 Locationshare fix 2025-12-29 15:42:55 +08:00
Tsubasahane
9f5cc9e0d1 Merge branch '2.0.2' into 2.0.2-Location 2025-12-29 14:48:07 +08:00
choco
b02db4c1e1 Merge remote-tracking branch 'origin/2.0.0-crashing-bugfixes' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
#	LightlessSync/UI/DtrEntry.cs
2025-12-28 16:56:06 +01:00
cake
d6b31ed5b9 Fixed finder again. 2025-12-28 16:55:01 +01:00
cake
9e600bfae0 Fixed merge conflicts. 2025-12-28 16:48:51 +01:00
cake
1a73d5a4d9 2.0.2 merged again 2025-12-28 16:40:47 +01:00
Tsubasahane
a933330418 Share location 2025-12-28 23:07:45 +08:00
Tsubasahane
ea34b18f40 Merge branch '2.0.2' into 2.0.2-Location 2025-12-28 13:10:17 +08:00
defnotken
67dc215e83 Merge branch '2.0.2-Location' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2-Location 2025-12-27 21:17:32 -06:00
defnotken
baf3869cec Merge conf 2025-12-27 21:17:26 -06:00
Tsubasahane
eeda5aeb66 Revert "Location Sharing"
This reverts commit 70745613e1.
2025-12-28 10:54:01 +08:00
choco
754df95071 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/UI/DtrEntry.cs
2025-12-27 23:13:20 +01:00
choco
24fca31606 join syncshell draw modal 2025-12-27 23:09:29 +01:00
choco
a99c1c01b0 Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-27 23:08:03 +01:00
choco
85999fab8f Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs
#	LightlessSync/UI/SyncshellFinderUI.cs
#	LightlessSync/UI/TopTabMenu.cs
#	LightlessSync/WebAPI/Files/FileDownloadManager.cs
2025-12-27 20:49:20 +01:00
Tsubasahane
70745613e1 Location Sharing 2025-12-27 19:57:21 +08:00
Tsubasahane
5c8e239a7b implement playerState
- use IPlayerState for DalamudUtilService and make things less asynced
- make LocationInfo work with ContentFinderData
2025-12-27 17:04:39 +08:00
choco
5eed65149a nearby lightfinder users window, wiht pair func 2025-12-27 02:38:56 +01:00
cake
1ab4e2f94b Added color options for header 2025-12-26 22:26:29 +01:00
choco
f792bc1954 compact ui design refactor with lightfinder redesign 2025-12-26 00:00:13 +01:00
choco
ced72ab9eb icon centering changes 2025-12-24 16:59:46 +01:00
choco
6c1cc77aaa settings animated header 2025-12-23 17:36:36 +01:00
choco
5b81caf5a8 compact menu redesign with new animated particle header, enable particles toggle added in UI settings 2025-12-23 17:16:51 +01:00
choco
4e03b381dc animated header main menu redesign test 2025-12-23 00:48:47 +01:00
choco
3222133aa0 Merge branch '2.0.1' into 2.0.0-crashing-bugfixes 2025-12-23 00:36:56 +01:00
choco
0ec423e65c potential resolve disposal crashes and race conditions 2025-12-21 22:34:39 +01:00
26 changed files with 2482 additions and 1646 deletions

View File

@@ -155,5 +155,6 @@ public class LightlessConfig : ILightlessConfiguration
public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
public bool EnableParticleEffects { get; set; } = true;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>2.0.2</Version>
<Version>2.0.3</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
@@ -37,6 +37,7 @@
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />

View File

@@ -140,6 +140,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<IdDisplayHandler>();
services.AddSingleton<PlayerPerformanceService>();
services.AddSingleton<PenumbraTempCollectionJanitor>();
services.AddSingleton<LocationShareService>();
services.AddSingleton<TextureMetadataHelper>(sp =>
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
@@ -480,19 +481,11 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessProfileManager>()));
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
services.AddScoped<IPopupHandler, CensusPopupHandler>();
@@ -578,7 +571,6 @@ public sealed class Plugin : IDalamudPlugin
public void Dispose()
{
_host.StopAsync().GetAwaiter().GetResult();
_host.Dispose();
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
}
}

View File

@@ -10,7 +10,6 @@ using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -172,9 +171,8 @@ internal class ContextMenuService : IHostedService
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
if (!IsWorldValid(target.TargetHomeWorld.RowId))
{
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
return;
@@ -226,9 +224,8 @@ internal class ContextMenuService : IHostedService
{
if (args.Target is not MenuTargetDefault target)
return;
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
return;
try
@@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService
if (targetData == null || targetData.Address == nint.Zero)
{
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name);
return;
}
@@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService
}
// Notify in chat when NotificationService is disabled
NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info);
NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info);
}
catch (Exception ex)
{
@@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
}
private World GetWorld(uint worldId)
private bool IsWorldValid(uint worldId)
{
var sheet = _gameData.GetExcelSheet<World>()!;
var luminaWorlds = sheet.Where(x =>
{
var dc = x.DataCenter.ValueNullable;
var name = x.Name.ExtractText();
var internalName = x.InternalName.ExtractText();
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
return false;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
return false;
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
return false;
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
});
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
}
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public static bool IsWorldValid(World world)
{
var name = world.Name.ToString();
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
}
}

View File

@@ -1,11 +1,13 @@
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using LightlessSync.API.Dto.CharaData;
@@ -26,6 +28,7 @@ using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using Map = Lumina.Excel.Sheets.Map;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
namespace LightlessSync.Services;
@@ -57,6 +60,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private string _lastGlobalBlockReason = string.Empty;
private ushort _lastZone = 0;
private ushort _lastWorldId = 0;
private uint _lastMapId = 0;
private bool _sentBetweenAreas = false;
private Lazy<ulong> _cid;
@@ -86,7 +90,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
WorldData = new(() =>
{
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|| w is { RowId: > 1000, Region: 101 or 201 }))
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
});
JobData = new(() =>
@@ -659,7 +664,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var location = new LocationInfo();
location.ServerId = _playerState.CurrentWorld.RowId;
//location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
location.TerritoryId = _clientState.TerritoryType;
location.MapId = _clientState.MapId;
if (houseMan != null)
@@ -685,7 +690,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var outside = houseMan->OutdoorTerritory;
var house = outside->HouseId;
location.WardId = house.WardIndex + 1u;
location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
//location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
location.DivisionId = houseMan->GetCurrentDivision();
}
//_logger.LogWarning(LocationToString(location));
@@ -713,10 +718,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
}
// if (location.InstanceId is not 0)
// {
// str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
// }
if (location.InstanceId is not 0)
{
str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
}
if (location.WardId is not 0)
{
@@ -914,6 +919,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
}
public void TargetPlayerByAddress(nint address)
{
if (address == nint.Zero) return;
if (_clientState.IsPvP) return;
_ = RunOnFrameworkThread(() =>
{
var gameObject = CreateGameObject(address);
if (gameObject is null) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
if (useFocusTarget)
{
_targetManager.FocusTarget = gameObject;
}
else
{
_targetManager.Target = gameObject;
}
});
}
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
{
var gameObj = (GameObject*)address;
@@ -1135,6 +1162,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.Publish(new ZoneSwitchEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
}
//Map
if (!_sentBetweenAreas)
{
var mapid = _clientState.MapId;
if (mapid != _lastMapId)
{
_lastMapId = mapid;
Mediator.Publish(new MapChangedMessage(mapid));
}
}
var localPlayer = _objectTable.LocalPlayer;
if (localPlayer != null)

View File

@@ -1,4 +1,4 @@
using Dalamud.Plugin.Services;
using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
@@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private readonly HashSet<string> _syncshellCids = [];
private volatile bool _pendingLocalBroadcast;
private TimeSpan? _pendingLocalTtl;
private string? _pendingLocalGid;
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
@@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private const int _maxQueueSize = 100;
private volatile bool _batchRunning = false;
private volatile bool _disposed = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
@@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
public void Update()
{
if (_disposed)
return;
_frameCounter++;
var lookupsThisFrame = 0;
@@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
{
if (_disposed)
return;
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
if (_disposed)
return;
var now = DateTime.UtcNow;
foreach (var (cid, info) in results)
@@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
}
if (_disposed)
return;
var activeCids = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
.Select(e => e.Key)
@@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
if (_disposed)
return;
if (!msg.Enabled)
{
_broadcastCache.Clear();
@@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_pendingLocalBroadcast = true;
_pendingLocalTtl = msg.Ttl;
_pendingLocalGid = msg.Gid;
TryPrimeLocalBroadcastCache();
}
@@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var expiry = DateTime.UtcNow + ttl;
_broadcastCache.AddOrUpdate(localCid,
new BroadcastEntry(true, expiry, null),
(_, old) => new BroadcastEntry(true, expiry, old.GID));
new BroadcastEntry(true, expiry, _pendingLocalGid),
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
_pendingLocalBroadcast = false;
_pendingLocalTtl = null;
_pendingLocalGid = null;
var now = DateTime.UtcNow;
var activeCids = _broadcastCache
@@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
}
private void UpdateSyncshellBroadcasts()
{
if (_disposed)
return;
var now = DateTime.UtcNow;
var nearbyCids = GetNearbyHashedCids(out _);
var newSet = nearbyCids.Count == 0
@@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
_disposed = true;
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
try
{
_cleanupTask?.Wait(100, _cleanupCts.Token);
_cleanupCts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed, can be ignored :)
}
_cleanupCts.Cancel();
_cleanupCts.Dispose();
try
{
_cleanupTask?.Wait(100);
}
catch (Exception)
{
// Task may have already completed or been cancelled?
}
_cleanupTask?.Wait(100);
_cleanupCts.Dispose();
try
{
_cleanupCts.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
}

View File

@@ -1,4 +1,4 @@
using Dalamud.Interface;
using Dalamud.Interface;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
@@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
_waitingForTtlFetch = false;
if (!wasEnabled || previousRemaining != validTtl)
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
{
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
}
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
return true;

View File

@@ -0,0 +1,137 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
namespace LightlessSync.Services
{
public class LocationShareService : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly ApiController _apiController;
private IMemoryCache _locations = new MemoryCache(new MemoryCacheOptions());
private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions());
private CancellationTokenSource _resetToken = new CancellationTokenSource();
public LocationShareService(ILogger<LocationShareService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator)
{
_dalamudUtilService = dalamudUtilService;
_apiController = apiController;
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
{
_resetToken.Cancel();
_resetToken.Dispose();
_resetToken = new CancellationTokenSource();
});
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
{
_ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData()));
_ = RequestAllLocation();
} );
Mediator.Subscribe<LocationSharingMessage>(this, UpdateLocationList);
Mediator.Subscribe<MapChangedMessage>(this,
msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData())));
}
private void UpdateLocationList(LocationSharingMessage msg)
{
if (_locations.TryGetValue(msg.User.UID, out _) && msg.LocationInfo.ServerId is 0)
{
_locations.Remove(msg.User.UID);
return;
}
if ( msg.LocationInfo.ServerId is not 0 && msg.ExpireAt > DateTime.UtcNow)
{
AddLocationInfo(msg.User.UID, msg.LocationInfo, msg.ExpireAt);
}
}
private void AddLocationInfo(string uid, LocationInfo location, DateTimeOffset expireAt)
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expireAt)
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
_locations.Set(uid, location, options);
}
private async Task RequestAllLocation()
{
try
{
var (data, status) = await _apiController.RequestAllLocationInfo().ConfigureAwait(false);
foreach (var dto in data)
{
AddLocationInfo(dto.LocationDto.User.UID, dto.LocationDto.Location, dto.ExpireAt);
}
foreach (var dto in status)
{
AddStatus(dto.User.UID, dto.ExpireAt);
}
}
catch (Exception e)
{
Logger.LogError(e,"RequestAllLocation error : ");
throw;
}
}
private void AddStatus(string uid, DateTimeOffset expireAt)
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expireAt)
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
_sharingStatus.Set(uid, expireAt, options);
}
public string GetUserLocation(string uid)
{
try
{
if (_locations.TryGetValue<LocationInfo>(uid, out var location))
{
return _dalamudUtilService.LocationToString(location);
}
return String.Empty;
}
catch (Exception e)
{
Logger.LogError(e,"GetUserLocation error : ");
throw;
}
}
public DateTimeOffset GetSharingStatus(string uid)
{
try
{
if (_sharingStatus.TryGetValue<DateTimeOffset>(uid, out var expireAt))
{
return expireAt;
}
return DateTimeOffset.MinValue;
}
catch (Exception e)
{
Logger.LogError(e,"GetSharingStatus error : ");
throw;
}
}
public void UpdateSharingStatus(List<string> users, DateTimeOffset expireAt)
{
foreach (var user in users)
{
AddStatus(user, expireAt);
}
}
}
}

View File

@@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService
_ = Task.Run(async () =>
{
while (!_loopCts.Token.IsCancellationRequested)
try
{
while (!_processQueue)
while (!_loopCts.Token.IsCancellationRequested)
{
while (!_processQueue)
{
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
HashSet<MessageBase> processedMessages = [];
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
HashSet<MessageBase> processedMessages = [];
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("LightlessMediator stopped");
}
});

View File

@@ -123,7 +123,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
public record UserLeftSyncshell(string gid) : MessageBase;
public record UserJoinedSyncshell(string gid) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
@@ -135,5 +135,7 @@ public record ChatChannelsUpdated : MessageBase;
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
public record GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase;
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

@@ -34,44 +34,65 @@ namespace LightlessSync.UI;
public class CompactUi : WindowMediatorSubscriberBase
{
private readonly CharacterAnalyzer _characterAnalyzer;
#region Constants
private const float ConnectButtonHighlightThickness = 14f;
#endregion
#region Services
private readonly ApiController _apiController;
private readonly CharacterAnalyzer _characterAnalyzer;
private readonly DalamudUtilService _dalamudUtilService;
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly IpcManager _ipcManager;
private readonly LightFinderService _broadcastService;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly PairLedger _pairLedger;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly RenamePairTagUi _renamePairTagUi;
private readonly IpcManager _ipcManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly ServerConfigurationManager _serverManager;
private readonly TopTabMenu _tabMenu;
private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService;
private readonly LightFinderService _broadcastService;
private readonly DalamudUtilService _dalamudUtilService;
#endregion
#region UI Components
private readonly AnimatedHeader _animatedHeader = new();
private readonly RenamePairTagUi _renamePairTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new();
private readonly TopTabMenu _tabMenu;
#endregion
#region State
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private List<IDrawFolder> _drawFolders;
private Pair? _focusedPair;
private Pair? _lastAddedUser;
private string _lastAddedUserComment = string.Empty;
private Vector2 _lastPosition = Vector2.One;
private Vector2 _lastSize = Vector2.One;
private int _pendingFocusFrame = -1;
private Pair? _pendingFocusPair;
private bool _showModalForUserAddition;
private float _transferPartHeight;
private bool _wasOpen;
private float _windowContentWidth;
private readonly SeluneBrush _seluneBrush = new();
private const float _connectButtonHighlightThickness = 14f;
private Pair? _focusedPair;
private Pair? _pendingFocusPair;
private int _pendingFocusFrame = -1;
#endregion
#region Constructor
public CompactUi(
ILogger<CompactUi> logger,
@@ -127,6 +148,11 @@ public class CompactUi : WindowMediatorSubscriberBase
.Apply();
_drawFolders = [.. DrawFolders];
_animatedHeader.Height = 120f;
_animatedHeader.EnableBottomGradient = true;
_animatedHeader.GradientHeight = 250f;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
#if DEBUG
string dev = "Dev Build";
@@ -150,9 +176,14 @@ public class CompactUi : WindowMediatorSubscriberBase
_lightlessMediator = mediator;
}
#endregion
#region Lifecycle
public override void OnClose()
{
ForceReleaseFocus();
_animatedHeader.ClearParticles();
base.OnClose();
}
@@ -164,6 +195,13 @@ public class CompactUi : WindowMediatorSubscriberBase
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
// Draw animated header background (just the gradient/particles, content drawn by existing methods)
var startCursorY = ImGui.GetCursorPosY();
_animatedHeader.Draw(_windowContentWidth, (_, _) => { });
// Reset cursor to draw content on top of the header background
ImGui.SetCursorPosY(startCursorY);
if (!_apiController.IsCurrentVersion)
{
var ver = _apiController.CurrentClientVersion;
@@ -209,17 +247,11 @@ public class CompactUi : WindowMediatorSubscriberBase
}
using (ImRaii.PushId("header")) DrawUIDHeader();
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
using (ImRaii.PushId("serverstatus"))
{
DrawServerStatus();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
var style = ImGui.GetStyle();
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
ImGui.Separator();
if (_apiController.ServerState is ServerState.Connected)
{
@@ -227,7 +259,6 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs();
ImGui.Separator();
var transfersTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
@@ -290,6 +321,10 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
#endregion
#region Content Drawing
private void DrawPairs()
{
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
@@ -308,95 +343,6 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.EndChild();
}
private void DrawServerStatus()
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture);
var userSize = ImGui.CalcTextSize(userCount);
var textSize = ImGui.CalcTextSize("Users Online");
#if DEBUG
string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}";
#else
string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}";
#endif
var shardTextSize = ImGui.CalcTextSize(shardConnection);
var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty;
if (_apiController.ServerState is ServerState.Connected)
{
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2);
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount);
ImGui.SameLine();
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Users Online");
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server");
}
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2);
ImGui.TextUnformatted(shardConnection);
}
ImGui.SameLine();
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X);
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
using (ImRaii.PushColor(ImGuiCol.Text, color))
{
if (_uiSharedService.IconButton(connectedIcon))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: _connectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
private void DrawTransfers()
{
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
@@ -492,11 +438,9 @@ public class CompactUi : WindowMediatorSubscriberBase
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
}
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
#region Header Drawing
private void DrawUIDHeader()
{
@@ -532,21 +476,52 @@ public class CompactUi : WindowMediatorSubscriberBase
using (_uiSharedService.IconFont.Push())
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
float uidStartX = (contentWidth - uidTextSize.X) / 2f;
float uidStartX = 25f;
float cursorY = ImGui.GetCursorPosY();
ImGui.SetCursorPosY(cursorY);
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
// Get the actual rendered text rect for proper icon alignment
var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin();
var uidTextRectMin = ImGui.GetItemRectMin();
var uidTextHovered = ImGui.IsItemHovered();
headerItemClicked = ImGui.IsItemClicked();
// Track position for icons next to UID text
// Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height
float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f;
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f;
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
{
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY));
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
ImGui.InvisibleButton("BroadcastIcon", buttonSize);
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString());
nextIconX = ImGui.GetItemRectMax().X + 6f;
if (ImGui.IsItemHovered())
@@ -618,50 +593,8 @@ public class CompactUi : WindowMediatorSubscriberBase
if (ImGui.IsItemClicked())
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.SetCursorPosY(cursorY);
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
ImGui.GetItemRectMin() - padding,
ImGui.GetItemRectMax() + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
}
headerItemClicked = ImGui.IsItemClicked();
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
UiSharedService.AttachToolTip("Click to copy");
// Warning threshold icon (next to lightfinder or UID text)
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
{
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
@@ -675,24 +608,30 @@ public class CompactUi : WindowMediatorSubscriberBase
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
{
ImGui.SameLine();
ImGui.SetCursorPosY(cursorY + 15f);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
ImGui.InvisibleButton("WarningThresholdIcon", buttonSize);
var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString());
string warningMessage = "";
if (isOverTriHold)
if (ImGui.IsItemHovered())
{
warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine;
string warningMessage = "";
if (isOverTriHold)
{
warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine;
}
if (isOverVRAMUsage)
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
}
UiSharedService.AttachToolTip(warningMessage);
}
if (isOverVRAMUsage)
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
}
UiSharedService.AttachToolTip(warningMessage);
if (ImGui.IsItemClicked())
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
@@ -701,6 +640,34 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
if (uidTextHovered)
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
uidTextRectMin - padding,
uidTextRectMin + uidTextRect + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
ImGui.SetTooltip("Click to copy");
}
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
// Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout)
DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y);
// Add spacing below the big UID
ImGuiHelpers.ScaledDummy(5f);
if (_apiController.ServerState is ServerState.Connected)
{
if (headerItemClicked)
@@ -708,10 +675,12 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.SetClipboardText(_apiController.DisplayName);
}
if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal))
// Only show smaller UID line if DisplayName differs from UID (custom vanity name)
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
if (hasCustomName)
{
var origTextSize = ImGui.CalcTextSize(_apiController.UID);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
ImGui.SetCursorPosX(uidStartX);
if (useVanityColors)
{
@@ -746,14 +715,88 @@ public class CompactUi : WindowMediatorSubscriberBase
{
ImGui.SetClipboardText(_apiController.UID);
}
// Users Online on same line as smaller UID (with separator)
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("|");
ImGui.SameLine();
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
}
else
{
// No custom name - just show Users Online aligned to uidStartX
ImGui.SetCursorPosX(uidStartX);
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
}
}
else
{
ImGui.SetCursorPosX(uidStartX);
UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor);
}
}
private void DrawConnectButton(float screenY, float textHeight)
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
// Position on right side, vertically centered with text
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
var windowPos = ImGui.GetWindowPos();
var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f;
var yOffset = (textHeight - buttonSize.Y) * 0.5f;
ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset));
using (ImRaii.PushColor(ImGuiCol.Text, color))
using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))))
{
if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: ConnectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
#endregion
#region Folder Building
private IEnumerable<IDrawFolder> DrawFolders
{
get
@@ -889,6 +932,10 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
#endregion
#region Filtering & Sorting
private static bool PassesFilter(PairUiEntry entry, string filter)
{
if (string.IsNullOrEmpty(filter)) return true;
@@ -1032,10 +1079,11 @@ public class CompactUi : WindowMediatorSubscriberBase
return SortGroupEntries(entries, group);
}
private void UiSharedService_GposeEnd()
{
IsOpen = _wasOpen;
}
#endregion
#region GPose Handlers
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
private void UiSharedService_GposeStart()
{
@@ -1043,6 +1091,10 @@ public class CompactUi : WindowMediatorSubscriberBase
IsOpen = false;
}
#endregion
#region Focus Tracking
private void RegisterFocusCharacter(Pair pair)
{
_pendingFocusPair = pair;
@@ -1088,4 +1140,16 @@ public class CompactUi : WindowMediatorSubscriberBase
_pendingFocusPair = null;
_pendingFocusFrame = -1;
}
#endregion
#region Helper Types
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
}

View File

@@ -37,6 +37,7 @@ public class DrawUserPair
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly LightlessConfigService _configService;
private readonly LocationShareService _locationShareService;
private readonly CharaDataManager _charaDataManager;
private readonly PairLedger _pairLedger;
private float _menuWidth = -1;
@@ -57,6 +58,7 @@ public class DrawUserPair
UiSharedService uiSharedService,
PlayerPerformanceConfigService performanceConfigService,
LightlessConfigService configService,
LocationShareService locationShareService,
CharaDataManager charaDataManager,
PairLedger pairLedger)
{
@@ -74,6 +76,7 @@ public class DrawUserPair
_uiSharedService = uiSharedService;
_performanceConfigService = performanceConfigService;
_configService = configService;
_locationShareService = locationShareService;
_charaDataManager = charaDataManager;
_pairLedger = pairLedger;
}
@@ -216,6 +219,48 @@ public class DrawUserPair
_ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions));
}
UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty));
ImGui.SetCursorPosX(10f);
_uiSharedService.IconText(FontAwesomeIcon.Globe);
ImGui.SameLine();
if (ImGui.BeginMenu("Toggle Location sharing"))
{
if (ImGui.MenuItem("Share for 30 Mins"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddMinutes(30));
}
if (ImGui.MenuItem("Share for 1 Hour"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(1));
}
if (ImGui.MenuItem("Share for 3 Hours"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(3));
}
if (ImGui.MenuItem("Share until manually stop"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MaxValue);
}
ImGui.Separator();
if (ImGui.MenuItem("Stop Sharing"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MinValue);
}
ImGui.EndMenu();
}
}
private async Task ToggleLocationSharing(List<string> users, DateTimeOffset expireAt)
{
var updated = await _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)).ConfigureAwait(false);
if (updated)
{
_locationShareService.UpdateSharingStatus(users, expireAt);
}
}
private void DrawIndividualMenu()
@@ -574,6 +619,71 @@ public class DrawUserPair
var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false);
var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky();
var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle;
var shareLocationIcon = FontAwesomeIcon.Globe;
var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID);
var shareLocation = !string.IsNullOrEmpty(location);
var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID);
var shareLocationToOther = expireAt > DateTimeOffset.UtcNow;
var shareColor = shareLocation switch
{
true when shareLocationToOther => UIColors.Get("LightlessGreen"),
true when !shareLocationToOther => UIColors.Get("LightlessBlue"),
_ => UIColors.Get("LightlessYellow"),
};
if (shareLocation || shareLocationToOther)
{
currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX);
ImGui.SameLine(currentRightSide);
using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther))
_uiSharedService.IconText(shareLocationIcon);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
if (_pair.IsOnline)
{
if (shareLocation)
{
if (!string.IsNullOrEmpty(location))
{
_uiSharedService.IconText(FontAwesomeIcon.LocationArrow);
ImGui.SameLine();
ImGui.TextUnformatted(location);
}
else
{
ImGui.TextUnformatted("Location info not updated, reconnect or wait for update.");
}
}
else
{
ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)");
}
}
else
{
ImGui.TextUnformatted("User not online. (´・ω・`)?");
}
ImGui.Separator();
if (shareLocationToOther)
{
ImGui.TextUnformatted("Sharing your location. ヾ(•ω•`)o");
if (expireAt != DateTimeOffset.MaxValue)
{
ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g"));
}
}
else
{
ImGui.TextUnformatted("NOT sharing your location.  ̄へ ̄");
}
ImGui.EndTooltip();
}
}
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky)
{

View File

@@ -2183,7 +2183,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
bool toggleClicked = false;
if (showToggle)
{
var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
Vector2 iconSize;
using (_uiSharedService.IconFont.Push())
{

View File

@@ -29,6 +29,7 @@ public class DrawEntityFactory
private readonly LightlessConfigService _configService;
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly LocationShareService _locationShareService;
private readonly CharaDataManager _charaDataManager;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly RenamePairTagUi _renamePairTagUi;
@@ -53,6 +54,7 @@ public class DrawEntityFactory
LightlessConfigService configService,
UiSharedService uiSharedService,
PlayerPerformanceConfigService playerPerformanceConfigService,
LocationShareService locationShareService,
CharaDataManager charaDataManager,
SelectTagForSyncshellUi selectTagForSyncshellUi,
RenameSyncshellTagUi renameSyncshellTagUi,
@@ -72,6 +74,7 @@ public class DrawEntityFactory
_configService = configService;
_uiSharedService = uiSharedService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_locationShareService = locationShareService;
_charaDataManager = charaDataManager;
_selectTagForSyncshellUi = selectTagForSyncshellUi;
_renameSyncshellTagUi = renameSyncshellTagUi;
@@ -162,6 +165,7 @@ public class DrawEntityFactory
_uiSharedService,
_playerPerformanceConfigService,
_configService,
_locationShareService,
_charaDataManager,
_pairLedger);
}

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly UiSharedService _uiShared;
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
private readonly NameplateService _nameplateService;
private readonly AnimatedHeader _animatedHeader = new();
private (int, int, FileCacheEntity) _currentProgress;
private bool _deleteAccountPopupModalShown = false;
private bool _deleteFilesPopupModalShown = false;
@@ -205,7 +207,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
_nameplateService = nameplateService;
_actorObjectService = actorObjectService;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
_animatedHeader.Height = 120f;
_animatedHeader.EnableBottomGradient = true;
_animatedHeader.GradientHeight = 250f;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
WindowBuilder.For(this)
.AllowPinning(true)
.AllowClickthrough(false)
@@ -241,6 +246,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
public override void OnClose()
{
_animatedHeader.ClearParticles();
_uiShared.EditTrackerPosition = false;
_uidToAddForIgnore = string.Empty;
_secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate();
@@ -255,8 +261,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
protected override void DrawInternal()
{
_animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { });
_ = _uiShared.DrawOtherPluginState();
DrawSettingsContent();
}
private static Vector3 PackedColorToVector3(uint color)
@@ -2089,7 +2095,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator();
var openPopupOnAddition = _configService.Current.OpenPopupOnAdd;
using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple")))
{
if (popupTree.Visible)
@@ -2146,11 +2152,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible;
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
var enableParticleEffects = _configService.Current.EnableParticleEffects;
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
{
if (behaviorTree.Visible)
{
if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects))
{
_configService.Current.EnableParticleEffects = enableParticleEffects;
_configService.Save();
}
_uiShared.DrawHelpText("This will enable particle effects in the UI.");
if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu))
{
_configService.Current.EnableRightClickMenus = enableRightClickMenu;
@@ -2859,16 +2874,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
var colorNames = new[]
{
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
("LightlessGreen", "Success Green", "Join buttons and success messages"),
("LightlessYellow", "Warning Yellow", "Warning colors"),
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
("DimRed", "Error Red", "Error and offline colors")
};
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
("LightlessGreen", "Success Green", "Join buttons and success messages"),
("LightlessYellow", "Warning Yellow", "Warning colors"),
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
("DimRed", "Error Red", "Error and offline colors"),
("HeaderGradientTop", "Header Gradient (Top)", "Top color of the animated header background"),
("HeaderGradientBottom", "Header Gradient (Bottom)", "Bottom color of the animated header background"),
("HeaderStaticStar", "Header Stars", "Tint color for the static background stars in the header"),
("HeaderShootingStar", "Header Shooting Star", "Tint color for the shooting star effect"),
};
if (ImGui.BeginTable("##ColorTable", 3,
ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
{

View File

@@ -43,10 +43,23 @@ public class AnimatedHeader
private const float _extendedParticleHeight = 40f;
public float Height { get; set; } = 150f;
// Color keys for theming
public string? TopColorKey { get; set; } = "HeaderGradientTop";
public string? BottomColorKey { get; set; } = "HeaderGradientBottom";
public string? StaticStarColorKey { get; set; } = "HeaderStaticStar";
public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar";
// Fallbacks if the color keys are not found
public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f);
public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f);
public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f);
public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f);
public bool EnableParticles { get; set; } = true;
public bool EnableBottomGradient { get; set; } = true;
public float GradientHeight { get; set; } = 60f;
/// <summary>
/// Draws the animated header with some customizable content
@@ -146,16 +159,21 @@ public class AnimatedHeader
{
var drawList = ImGui.GetWindowDrawList();
var top = ResolveColor(TopColorKey, TopColor);
var bottom = ResolveColor(BottomColorKey, BottomColor);
drawList.AddRectFilledMultiColor(
headerStart,
headerEnd,
ImGui.GetColorU32(TopColor),
ImGui.GetColorU32(TopColor),
ImGui.GetColorU32(BottomColor),
ImGui.GetColorU32(BottomColor)
ImGui.GetColorU32(top),
ImGui.GetColorU32(top),
ImGui.GetColorU32(bottom),
ImGui.GetColorU32(bottom)
);
// Draw static background stars
var starBase = ResolveColor(StaticStarColorKey, StaticStarColor);
var random = new Random(42);
for (int i = 0; i < 50; i++)
{
@@ -164,23 +182,28 @@ public class AnimatedHeader
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
);
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness)));
var starColor = starBase with { W = starBase.W * brightness };
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor));
}
}
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
{
var drawList = ImGui.GetWindowDrawList();
var gradientHeight = 60f;
var gradientHeight = GradientHeight;
var bottom = ResolveColor(BottomColorKey, BottomColor);
for (int i = 0; i < gradientHeight; i++)
{
var progress = i / gradientHeight;
var smoothProgress = progress * progress;
var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress;
var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress;
var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress;
var r = bottom.X + (0.0f - bottom.X) * smoothProgress;
var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress;
var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress;
var alpha = 1f - smoothProgress;
var gradientColor = new Vector4(r, g, b, alpha);
drawList.AddLine(
new Vector2(headerStart.X, headerEnd.Y + i),
@@ -308,9 +331,11 @@ public class AnimatedHeader
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
: baseAlpha;
var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor);
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
{
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
var baseColor = shootingBase;
for (int t = 1; t < particle.Trail.Count; t++)
{
@@ -319,17 +344,18 @@ public class AnimatedHeader
var trailWidth = (1f - trailProgress) * 3f + 1f;
var glowAlpha = trailAlpha * 0.4f;
drawList.AddLine(
bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = glowAlpha }),
ImGui.GetColorU32(baseColor with { W = glowAlpha }),
trailWidth + 4f
);
drawList.AddLine(
bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
ImGui.GetColorU32(baseColor with { W = trailAlpha }),
trailWidth
);
}
@@ -448,6 +474,13 @@ public class AnimatedHeader
Hue = 270f
});
}
private static Vector4 ResolveColor(string? key, Vector4 fallback)
{
if (string.IsNullOrWhiteSpace(key))
return fallback;
return UIColors.Get(key);
}
/// <summary>
/// Clears all active particles. Useful when closing or hiding a window with an animated header.

View File

@@ -40,9 +40,10 @@ internal static class MainStyle
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered),
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed),
new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed),
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),

View File

@@ -1,855 +0,0 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Plugin.Services;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Group;
using LightlessSync.Services;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using LightlessSync.UI.Tags;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
using System.Numerics;
namespace LightlessSync.UI;
public class SyncshellFinderUI : WindowMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly LightFinderService _broadcastService;
private readonly UiSharedService _uiSharedService;
private readonly LightFinderScannerService _broadcastScannerService;
private readonly PairUiService _pairUiService;
private readonly DalamudUtilService _dalamudUtilService;
private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f);
private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f);
private readonly List<SeStringUtils.SeStringSegment> _seResolvedSegments = new();
private readonly List<GroupJoinDto> _nearbySyncshells = [];
private List<GroupFullInfoDto> _currentSyncshells = [];
private int _selectedNearbyIndex = -1;
private int _syncshellPageIndex = 0;
private readonly HashSet<string> _recentlyJoined = new(StringComparer.Ordinal);
private GroupJoinDto? _joinDto;
private GroupJoinInfoDto? _joinInfo;
private DefaultPermissionsDto _ownPermissions = null!;
private bool _useTestSyncshells = false;
private bool _compactView = false;
private readonly LightlessProfileManager _lightlessProfileManager;
public SyncshellFinderUI(
ILogger<SyncshellFinderUI> logger,
LightlessMediator mediator,
PerformanceCollectorService performanceCollectorService,
LightFinderService broadcastService,
UiSharedService uiShared,
ApiController apiController,
LightFinderScannerService broadcastScannerService,
PairUiService pairUiService,
DalamudUtilService dalamudUtilService,
LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService)
{
_broadcastService = broadcastService;
_uiSharedService = uiShared;
_apiController = apiController;
_broadcastScannerService = broadcastScannerService;
_pairUiService = pairUiService;
_dalamudUtilService = dalamudUtilService;
_lightlessProfileManager = lightlessProfileManager;
IsOpen = false;
WindowBuilder.For(this)
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550))
.Apply();
Mediator.Subscribe<SyncshellBroadcastsUpdatedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<UserLeftSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
Mediator.Subscribe<UserJoinedSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
}
public override async void OnOpen()
{
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
await RefreshSyncshellsAsync().ConfigureAwait(false);
}
protected override void DrawInternal()
{
ImGui.BeginGroup();
_uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple"));
#if DEBUG
if (ImGui.SmallButton("Show test syncshells"))
{
_useTestSyncshells = !_useTestSyncshells;
_ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false));
}
ImGui.SameLine();
#endif
string checkboxLabel = "Compact view";
float availWidth = ImGui.GetContentRegionAvail().X;
float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight();
float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f;
ImGui.SetCursorPosX(rightX);
ImGui.Checkbox(checkboxLabel, ref _compactView);
ImGui.EndGroup();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
if (_nearbySyncshells.Count == 0)
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
if (!_broadcastService.IsBroadcasting)
{
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"));
ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active.");
ImGuiHelpers.ScaledDummy(0.5f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"));
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
{
Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.PopStyleColor();
ImGui.PopStyleVar();
return;
}
return;
}
var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? [];
_broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid);
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>();
foreach (var shell in _nearbySyncshells)
{
string broadcasterName;
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
continue;
if (_useTestSyncshells)
{
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
? shell.Group.Alias
: shell.Group.GID;
broadcasterName = $"{displayName} (Tester of TestWorld)";
}
else
{
var broadcast = broadcasts
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
if (broadcast == null)
continue;
var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
if (string.IsNullOrEmpty(name))
continue;
var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address);
broadcasterName = !string.IsNullOrEmpty(worldName)
? $"{name} ({worldName})"
: name;
var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid)
&& string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal);
cardData.Add((shell, broadcasterName, isSelfBroadcast));
continue;
}
cardData.Add((shell, broadcasterName, false));
}
if (cardData.Count == 0)
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
return;
}
if (_compactView)
{
DrawSyncshellGrid(cardData);
}
else
{
DrawSyncshellList(cardData);
}
if (_joinDto != null && _joinInfo != null && _joinInfo.Success)
DrawConfirmation();
}
private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData)
{
const int shellsPerPage = 3;
var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage);
if (totalPages <= 0)
totalPages = 1;
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
var firstIndex = _syncshellPageIndex * shellsPerPage;
var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
for (int index = firstIndex; index < lastExclusive; index++)
{
var (shell, broadcasterName, isSelfBroadcast) = listData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
ImGui.PushID(shell.Group.GID);
float rowHeight = 74f * ImGuiHelpers.GlobalScale;
ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true);
var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID;
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float regionW = ImGui.GetContentRegionAvail().X;
float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X;
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
{
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
}
float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X;
ImGui.SameLine();
ImGui.SetCursorPosX(rightX);
ImGui.TextUnformatted(broadcasterLabel);
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Broadcaster of the syncshell.");
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
IReadOnlyList<ProfileTagDefinition> groupTags =
groupProfile != null && groupProfile.Tags.Count > 0
? ProfileTagService.ResolveTags(groupProfile.Tags)
: [];
var limitedTags = groupTags.Count > 3
? [.. groupTags.Take(3)]
: groupTags;
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
Vector2 rowStartLocal = ImGui.GetCursorPos();
float tagsWidth = 0f;
float tagsHeight = 0f;
if (limitedTags.Count > 0)
{
(tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
}
else
{
ImGui.SetCursorPosX(startX);
ImGui.TextDisabled("-- No tags set --");
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
float btnBaselineY = rowStartLocal.Y;
float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f);
ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY));
DrawJoinButton(shell, isSelfBroadcast);
float btnHeight = ImGui.GetFrameHeightWithSpacing();
float rowHeightUsed = MathF.Max(tagsHeight, btnHeight);
ImGui.SetCursorPos(new Vector2(
rowStartLocal.X,
rowStartLocal.Y + rowHeightUsed));
ImGui.EndChild();
ImGui.PopID();
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
}
ImGui.PopStyleVar(2);
DrawPagination(totalPages);
}
private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData)
{
const int shellsPerPage = 4;
var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage);
if (totalPages <= 0)
totalPages = 1;
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
var firstIndex = _syncshellPageIndex * shellsPerPage;
var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count);
var avail = ImGui.GetContentRegionAvail();
var spacing = ImGui.GetStyle().ItemSpacing;
var cardWidth = (avail.X - spacing.X) / 2.0f;
var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f;
cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
for (int index = firstIndex; index < lastExclusive; index++)
{
var localIndex = index - firstIndex;
var (shell, broadcasterName, isSelfBroadcast) = cardData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
if (localIndex % 2 != 0)
ImGui.SameLine();
ImGui.PushID(shell.Group.GID);
ImGui.BeginGroup();
_ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true);
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
? shell.Group.Alias
: shell.Group.GID;
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float availW = ImGui.GetContentRegionAvail().X;
ImGui.BeginGroup();
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
{
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
}
float nameRightX = ImGui.GetItemRectMax().X;
var regionMinScreen = ImGui.GetCursorScreenPos();
float regionRightX = regionMinScreen.X + availW;
float minBroadcasterX = nameRightX + style.ItemSpacing.X;
float maxBroadcasterWidth = regionRightX - minBroadcasterX;
string broadcasterToShow = broadcasterLabel;
if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f)
{
float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X;
string toolTip;
if (bcFullWidth > maxBroadcasterWidth)
{
broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth);
toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell.";
}
else
{
toolTip = "Broadcaster of the syncshell.";
}
float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X;
float broadX = regionRightX - bcWidth;
broadX = MathF.Max(broadX, minBroadcasterX);
ImGui.SameLine();
var curPos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale));
ImGui.TextUnformatted(broadcasterToShow);
if (ImGui.IsItemHovered())
ImGui.SetTooltip(toolTip);
}
ImGui.EndGroup();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale));
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
IReadOnlyList<ProfileTagDefinition> groupTags =
groupProfile != null && groupProfile.Tags.Count > 0
? ProfileTagService.ResolveTags(groupProfile.Tags)
: [];
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
if (groupTags.Count > 0)
{
var limitedTags = groupTags.Count > 2
? [.. groupTags.Take(2)]
: groupTags;
ImGui.SetCursorPosX(startX);
var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
else
{
ImGui.SetCursorPosX(startX);
ImGui.TextDisabled("-- No tags set --");
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
var buttonHeight = ImGui.GetFrameHeightWithSpacing();
var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight;
if (remainingY > 0)
ImGui.Dummy(new Vector2(0, remainingY));
DrawJoinButton(shell, isSelfBroadcast);
ImGui.EndChild();
ImGui.EndGroup();
ImGui.PopID();
}
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
ImGui.PopStyleVar(2);
DrawPagination(totalPages);
}
private void DrawPagination(int totalPages)
{
if (totalPages > 1)
{
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
var style = ImGui.GetStyle();
string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}";
float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2;
float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2;
float textWidth = ImGui.CalcTextSize(pageLabel).X;
float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2;
float availWidth = ImGui.GetContentRegionAvail().X;
float offsetX = (availWidth - totalWidth) * 0.5f;
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX);
if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0)
_syncshellPageIndex--;
ImGui.SameLine();
ImGui.Text(pageLabel);
ImGui.SameLine();
if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1)
_syncshellPageIndex++;
}
}
private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast)
{
const string visibleLabel = "Join";
var label = $"{visibleLabel}##{shell.Group.GID}";
var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal));
var isRecentlyJoined = _recentlyJoined.Contains(shell.GID);
Vector2 buttonSize;
if (!_compactView)
{
var style = ImGui.GetStyle();
var textSize = ImGui.CalcTextSize(visibleLabel);
var width = textSize.X + style.FramePadding.X * 20f;
buttonSize = new Vector2(width, 30f);
float availX = ImGui.GetContentRegionAvail().X;
float curX = ImGui.GetCursorPosX();
float newX = curX + (availX - buttonSize.X);
ImGui.SetCursorPosX(newX);
}
else
{
buttonSize = new Vector2(-1, 0);
}
if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast)
{
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f));
if (ImGui.Button(label, buttonSize))
{
_logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})");
_ = Task.Run(async () =>
{
try
{
var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto(
shell.Group,
shell.Password,
shell.GroupUserPreferredPermissions
)).ConfigureAwait(false);
if (info != null && info.Success)
{
_joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions);
_joinInfo = info;
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
_logger.LogInformation($"Fetched join info for {shell.Group.GID}");
}
else
{
_logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful");
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Join failed for {shell.Group.GID}");
}
});
}
}
else
{
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed"));
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f));
using (ImRaii.Disabled())
{
ImGui.Button(label, buttonSize);
}
UiSharedService.AttachToolTip(isSelfBroadcast
? "This is your own Syncshell."
: "Already a member or owner of this Syncshell.");
}
ImGui.PopStyleColor(3);
}
private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList<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} for profile tags", iconId);
}
return null;
}
private void DrawConfirmation()
{
if (_joinDto != null && _joinInfo != null)
{
ImGui.Separator();
ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}");
ImGuiHelpers.ScaledDummy(2f);
ImGui.TextUnformatted("Suggested Syncshell Permissions:");
DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v);
DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v);
DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v);
ImGui.NewLine();
ImGui.NewLine();
if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}"))
{
var finalPermissions = GroupUserPreferredPermissions.NoneSet;
finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds);
finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations);
finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX);
_ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions));
_recentlyJoined.Add(_joinDto.Group.GID);
_joinDto = null;
_joinInfo = null;
}
}
}
private void DrawPermissionRow(string label, bool suggested, bool current, Action<bool> apply)
{
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted($"- {label}");
ImGui.SameLine(150 * ImGuiHelpers.GlobalScale);
ImGui.TextUnformatted("Current:");
ImGui.SameLine();
_uiSharedService.BooleanToColoredIcon(!current);
ImGui.SameLine(300 * ImGuiHelpers.GlobalScale);
ImGui.TextUnformatted("Suggested:");
ImGui.SameLine();
_uiSharedService.BooleanToColoredIcon(!suggested);
ImGui.SameLine(450 * ImGuiHelpers.GlobalScale);
using var id = ImRaii.PushId(label);
if (current != suggested)
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply"))
apply(suggested);
}
ImGui.NewLine();
}
private async Task RefreshSyncshellsAsync(string? gid = null)
{
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
var snapshot = _pairUiService.GetSnapshot();
_currentSyncshells = [.. snapshot.GroupPairs.Keys];
_recentlyJoined.RemoveWhere(gid =>
_currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));
List<GroupJoinDto>? updatedList = [];
if (_useTestSyncshells)
{
updatedList = BuildTestSyncshells();
}
else
{
if (syncshellBroadcasts.Count == 0)
{
ClearSyncshells();
return;
}
try
{
var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts)
.ConfigureAwait(false);
updatedList = groups?.DistinctBy(g => g.Group.GID).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh broadcasted syncshells.");
return;
}
}
if (updatedList == null || updatedList.Count == 0)
{
ClearSyncshells();
return;
}
if (gid != null && _recentlyJoined.Contains(gid))
{
_recentlyJoined.Clear();
}
var previousGid = GetSelectedGid();
_nearbySyncshells.Clear();
_nearbySyncshells.AddRange(updatedList);
if (previousGid != null)
{
var newIndex = _nearbySyncshells.FindIndex(s =>
string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{
_selectedNearbyIndex = newIndex;
return;
}
}
ClearSelection();
}
private static List<GroupJoinDto> 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;
}
}

View File

@@ -162,24 +162,32 @@ public class TopTabMenu
ImGui.SameLine();
using (ImRaii.PushFont(UiBuilder.IconFont))
{
var x = ImGui.GetCursorScreenPos();
if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize))
{
TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder;
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
if (TabSelection == SelectedTab.Lightfinder)
drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y },
xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X },
underlineColor, 2);
}
UiSharedService.AttachToolTip("Lightfinder");
var nearbyCount = GetNearbySyncshellCount();
if (nearbyCount > 0)
{
var buttonMax = ImGui.GetItemRectMax();
var badgeRadius = 8f * ImGuiHelpers.GlobalScale;
var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 1.3f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f);
var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString();
var textSize = ImGui.CalcTextSize(badgeText);
drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f)));
drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple")));
var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f);
drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText);
}
UiSharedService.AttachToolTip(nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder");
ImGui.SameLine();
using (ImRaii.PushFont(UiBuilder.IconFont))
@@ -234,10 +242,7 @@ public class TopTabMenu
DrawSyncshellMenu(availableWidth, spacing.X);
DrawGlobalSyncshellButtons(availableWidth, spacing.X);
}
else if (TabSelection == SelectedTab.Lightfinder)
{
DrawLightfinderMenu(availableWidth, spacing.X);
}
else if (TabSelection == SelectedTab.UserConfig)
{
DrawUserConfig(availableWidth, spacing.X);
@@ -776,53 +781,22 @@ public class TopTabMenu
}
}
}
private void DrawLightfinderMenu(float availableWidth, float spacingX)
{
var buttonX = (availableWidth - (spacingX)) / 2f;
var lightFinderLabel = GetLightfinderFinderLabel();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightFinderLabel, buttonX, center: true))
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.SameLine();
var syncshellFinderLabel = GetSyncshellFinderLabel();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true))
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI)));
}
}
private string GetLightfinderFinderLabel()
{
string label = "Lightfinder";
if (_lightFinderService.IsBroadcasting)
{
var hashExclude = _dalamudUtilService.GetCID().ToString().GetHash256();
var nearbyCount = _lightFinderScannerService.GetActiveBroadcasts(hashExclude).Count;
return $"{label} ({nearbyCount})";
}
return label;
}
private string GetSyncshellFinderLabel()
private int GetNearbySyncshellCount()
{
if (!_lightFinderService.IsBroadcasting)
return "Syncshell Finder";
return 0;
var nearbyCount = _lightFinderScannerService
.GetActiveSyncshellBroadcasts(excludeLocal: true)
.Where(b => !string.IsNullOrEmpty(b.GID))
var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256();
return _lightFinderScannerService
.GetActiveSyncshellBroadcasts()
.Where(b =>
!string.IsNullOrEmpty(b.GID) &&
!string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal))
.Select(b => b.GID!)
.Distinct(StringComparer.Ordinal)
.Count();
return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder";
}
private void DrawUserConfig(float availableWidth, float spacingX)

View File

@@ -6,7 +6,7 @@ namespace LightlessSync.UI
{
internal static class UIColors
{
private static readonly Dictionary<string, string> DefaultHexColors = new(StringComparer.OrdinalIgnoreCase)
private static readonly Dictionary<string, string> _defaultHexColors = new(StringComparer.OrdinalIgnoreCase)
{
{ "LightlessPurple", "#ad8af5" },
{ "LightlessPurpleActive", "#be9eff" },
@@ -31,6 +31,12 @@ namespace LightlessSync.UI
{ "ProfileBodyGradientTop", "#2f283fff" },
{ "ProfileBodyGradientBottom", "#372d4d00" },
{ "HeaderGradientTop", "#140D26FF" },
{ "HeaderGradientBottom", "#1F1433FF" },
{ "HeaderStaticStar", "#FFFFFFFF" },
{ "HeaderShootingStar", "#66CCFFFF" },
};
private static LightlessConfigService? _configService;
@@ -45,7 +51,7 @@ namespace LightlessSync.UI
if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true)
return HexToRgba(customColorHex);
if (!DefaultHexColors.TryGetValue(name, out var hex))
if (!_defaultHexColors.TryGetValue(name, out var hex))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
return HexToRgba(hex);
@@ -53,7 +59,7 @@ namespace LightlessSync.UI
public static void Set(string name, Vector4 color)
{
if (!DefaultHexColors.ContainsKey(name))
if (!_defaultHexColors.ContainsKey(name))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
if (_configService != null)
@@ -83,7 +89,7 @@ namespace LightlessSync.UI
public static Vector4 GetDefault(string name)
{
if (!DefaultHexColors.TryGetValue(name, out var hex))
if (!_defaultHexColors.TryGetValue(name, out var hex))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
return HexToRgba(hex);
@@ -96,7 +102,7 @@ namespace LightlessSync.UI
public static IEnumerable<string> GetColorNames()
{
return DefaultHexColors.Keys;
return _defaultHexColors.Keys;
}
public static Vector4 HexToRgba(string hexColor)

View File

@@ -40,6 +40,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
logger.LogInformation("UpdateNotesUi constructor called");
_uiShared = uiShared;
_configService = configService;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
RespectCloseHotkey = true;
ShowCloseButton = true;
@@ -48,7 +49,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
PositionCondition = ImGuiCond.Always;
WindowBuilder.For(this)
.AllowPinning(false)
.AllowClickthrough(false)

View File

@@ -200,5 +200,21 @@ public partial class ApiController
await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false);
}
public async Task UpdateLocation(LocationDto locationDto, bool offline = false)
{
if (!IsConnected) return;
await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false);
}
public async Task<(List<LocationWithTimeDto>, List<SharingStatusDto>)> RequestAllLocationInfo()
{
if (!IsConnected) return ([],[]);
return await _lightlessHub!.InvokeAsync<(List<LocationWithTimeDto>, List<SharingStatusDto>)>(nameof(RequestAllLocationInfo)).ConfigureAwait(false);
}
public async Task<bool> ToggleLocationSharing(LocationSharingToggleDto dto)
{
if (!IsConnected) return false;
return await _lightlessHub!.InvokeAsync<bool>(nameof(ToggleLocationSharing), dto).ConfigureAwait(false);
}
}
#pragma warning restore MA0040

View File

@@ -259,6 +259,13 @@ public partial class ApiController
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData)));
return Task.CompletedTask;
}
public Task Client_SendLocationToClient(LocationDto locationDto, DateTimeOffset expireAt)
{
Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.User} {expireAt}");
ExecuteSafely(() => Mediator.Publish(new LocationSharingMessage(locationDto.User, locationDto.Location, expireAt)));
return Task.CompletedTask;
}
public void OnDownloadReady(Action<Guid> act)
{
@@ -441,6 +448,12 @@ public partial class ApiController
_lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act);
}
public void OnReceiveLocation(Action<LocationDto, DateTimeOffset> act)
{
if (_initialized) return;
_lightlessHub!.On(nameof(Client_SendLocationToClient), act);
}
private void ExecuteSafely(Action act)
{
try

View File

@@ -606,6 +606,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto));
OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data));
OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data));
OnReceiveLocation((dto, time) => _ = Client_SendLocationToClient(dto, time));
_healthCheckTokenSource?.Cancel();
_healthCheckTokenSource?.Dispose();
@@ -774,5 +775,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
ServerState = state;
}
}
#pragma warning restore MA0040