Files
LightlessClient/LightlessSync/Services/ContextMenuService.cs
defnotken 72a62b7449
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
2.1.0 (#123)
# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which:

# Location Sharing (Big shout out to @tsubasahane for bringing this feature)

- Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)

[1]

# Model Optimization (Mesh Decimating)
 - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>)
 - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>)
 - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking.
 - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>)
+ ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE  **

[2]

# Animation (PAP) Validation (Safer animations)
 - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>)
 - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>)
 - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>)

# UI Changes (Thanks to @kyuwu for UI Changes)
- The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>)

[3]

- Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>)
- The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>)
- Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>)

# LightFinder / ShellFinder
- UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does.  [#127](<#127>)

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
2026-01-20 19:43:00 +00:00

317 lines
12 KiB
C#

using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
internal class ContextMenuService : IHostedService
{
private readonly IContextMenu _contextMenu;
private readonly IChatGui _chatGui;
private readonly IDalamudPluginInterface _pluginInterface;
private readonly IDataManager _gameData;
private readonly ILogger<ContextMenuService> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly IClientState _clientState;
private readonly PairUiService _pairUiService;
private readonly PairRequestService _pairRequestService;
private readonly ApiController _apiController;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly NotificationService _lightlessNotification;
private readonly LightFinderScannerService _broadcastScannerService;
private readonly LightFinderService _broadcastService;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly LightlessMediator _mediator;
private const int _lightlessPrefixColor = 708;
public ContextMenuService(
IContextMenu contextMenu,
IDalamudPluginInterface pluginInterface,
IDataManager gameData,
ILogger<ContextMenuService> logger,
DalamudUtilService dalamudUtil,
ApiController apiController,
IObjectTable objectTable,
LightlessConfigService configService,
PairRequestService pairRequestService,
PairUiService pairUiService,
IClientState clientState,
LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator,
IChatGui chatGui,
NotificationService lightlessNotification)
{
_contextMenu = contextMenu;
_pluginInterface = pluginInterface;
_gameData = gameData;
_logger = logger;
_dalamudUtil = dalamudUtil;
_apiController = apiController;
_objectTable = objectTable;
_configService = configService;
_pairUiService = pairUiService;
_pairRequestService = pairRequestService;
_clientState = clientState;
_broadcastScannerService = broadcastScannerService;
_broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager;
_mediator = mediator;
_chatGui = chatGui;
_lightlessNotification = lightlessNotification;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_contextMenu.OnMenuOpened += OnMenuOpened;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_contextMenu.OnMenuOpened -= OnMenuOpened;
return Task.CompletedTask;
}
public void Enable()
{
_contextMenu.OnMenuOpened += OnMenuOpened;
_logger.LogDebug("Context menu enabled.");
}
public void Disable()
{
_contextMenu.OnMenuOpened -= OnMenuOpened;
_logger.LogDebug("Context menu disabled.");
}
private void OnMenuOpened(IMenuOpenedArgs args)
{
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return;
if (!_configService.Current.EnableRightClickMenus)
{
_logger.LogTrace("Right-click menus are disabled in configuration.");
return;
}
if (args.AddonName != null)
{
var addonName = args.AddonName;
_logger.LogTrace("Context menu addon name: {AddonName}", addonName);
return;
}
if (args.Target is not MenuTargetDefault target)
{
_logger.LogTrace("Context menu target is not MenuTargetDefault.");
return;
}
_logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null");
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
{
_logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId);
return;
}
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null)
{
_logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
p.PlayerCharacterId != uint.MaxValue &&
p.PlayerCharacterId == target.TargetObjectId);
if (pair is not null)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId);
pair.AddContextMenu(args);
if (!pair.IsDirectlyPaired)
{
_logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId);
AddDirectPairMenuItem(args);
}
return;
}
_logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId);
//Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
{
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
if (!IsWorldValid(target.TargetHomeWorld.RowId))
{
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
string? targetHashedCid = null;
if (_broadcastService.IsBroadcasting)
{
targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
}
if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid))
{
var hashedCid = targetHashedCid;
UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid));
}
AddDirectPairMenuItem(args);
}
private void AddDirectPairMenuItem(IMenuOpenedArgs args)
{
UiSharedService.AddContextMenuItem(
args,
name: "Send Direct Pair Request",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () => HandleSelection(args));
}
private HashSet<ulong> VisibleUserIds =>
[.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
.Select(p => (ulong)p.PlayerCharacterId)];
private void NotifyInChat(string message, NotificationType type = NotificationType.Info)
{
if (!_configService.Current.UseLightlessNotifications || (_configService.Current.LightlessPairRequestNotification == NotificationLocation.Chat || _configService.Current.LightlessPairRequestNotification == NotificationLocation.ChatAndLightlessUi))
{
var chatMsg = $"[Lightless] {message}";
if (type == NotificationType.Error)
_chatGui.PrintError(chatMsg);
else
_chatGui.Print(chatMsg);
}
}
private async Task HandleSelection(IMenuArgs args)
{
if (args.Target is not MenuTargetDefault target)
return;
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
return;
try
{
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero)
{
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name);
return;
}
var senderCid = _dalamudUtil.GetCID().ToString().GetHash256();
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
await _apiController.TryPairWithContentId(receiverCid).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(receiverCid))
{
_pairRequestService.RemoveRequest(receiverCid);
}
// Notify in chat when NotificationService is disabled
NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending pair request.");
}
}
private async Task HandleLightfinderProfileSelection(string hashedCid)
{
if (string.IsNullOrWhiteSpace(hashedCid))
return;
if (!_broadcastService.IsBroadcasting)
{
Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6);
return;
}
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow)
{
Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5);
return;
}
var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false);
if (result == null)
{
Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6);
return;
}
_mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid));
}
private void Notify(string title, string message, NotificationType type, double durationSeconds)
{
_mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds)));
}
private bool CanOpenLightfinderProfile(string hashedCid)
{
if (!_broadcastService.IsBroadcasting)
return false;
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry))
return false;
return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow;
}
private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
{
return _objectTable
.OfType<IPlayerCharacter>()
.FirstOrDefault(p =>
string.Equals(p.Name.TextValue, target.TargetName, StringComparison.OrdinalIgnoreCase) &&
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
}
private bool IsWorldValid(uint worldId)
{
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
}
}