Compare commits
149 Commits
1.12.2.0-D
...
1.12.3.1-D
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68595d7b1 | ||
|
|
becba8e221 | ||
|
|
9c794137c1 | ||
|
|
4a256f7807 | ||
|
|
25756561b9 | ||
|
|
e8c546c128 | ||
| d4ba1cf437 | |||
| e0d1f98c70 | |||
|
|
1862689b1b | ||
| 325dc8947d | |||
|
|
95e7f2daa7 | ||
|
|
41a303dc91 | ||
|
|
25b03aea15 | ||
|
|
b6564156f0 | ||
|
|
f89ce900c7 | ||
| 299abc21ee | |||
|
|
c02a8ed2ee | ||
|
|
8692e877cf | ||
|
|
7de72471bb | ||
|
|
d7182e9d57 | ||
| 2b02de731a | |||
|
|
e9082ab8d0 | ||
| 2a06a11cbc | |||
|
|
557121a9b7 | ||
| b22140a8d4 | |||
| 4db468a480 | |||
| 8d8f8d20cd | |||
| 3722b79615 | |||
| cf97e7e800 | |||
|
|
1d672d2552 | ||
|
|
35636f27f6 | ||
|
|
1b686e45dc | ||
|
|
b6aa2bebb1 | ||
|
|
cfc9f60176 | ||
|
|
d4dca455ba | ||
| 76c2777f00 | |||
|
|
0af2a6134b | ||
|
|
6e3c60f627 | ||
|
|
5feb74c1c0 | ||
|
|
c1770528f3 | ||
|
|
bf139c128b | ||
|
|
b3cc41382f | ||
|
|
7c4d0fd5e9 | ||
|
|
c37e3badf1 | ||
| f4478f653a | |||
|
|
3f85852618 | ||
|
|
3e626c5e47 | ||
|
|
9a846a37d4 | ||
|
|
177534d78b | ||
|
|
de75b90703 | ||
|
|
c16891021c | ||
| d19d1c0a3a | |||
|
|
cabc4ec0fe | ||
|
|
8bccdc5ef1 | ||
|
|
ce5f8a43a2 | ||
|
|
437731749f | ||
|
|
55e78e088a | ||
| 5abc297a94 | |||
|
|
8aad714918 | ||
| b5bdededae | |||
|
|
487156e4f9 | ||
| 90d8f691d2 | |||
|
|
764bb8bae2 | ||
| 5d2c58bf3e | |||
|
|
6bb00c50d8 | ||
|
|
1a89c2caee | ||
|
|
1e97f27cb8 | ||
|
|
7aadbcec10 | ||
|
|
a32ac02c6d | ||
| f48698373b | |||
| ee20b6fa5f | |||
| f11741225b | |||
|
|
147baa4c1b | ||
|
|
4f5ef8ff4b | ||
|
|
fae6d31792 | ||
|
|
b4dd0ee0e1 | ||
| f5458c7f97 | |||
| 923f118a47 | |||
|
|
0cb71e5444 | ||
|
|
268fd471fe | ||
|
|
d517a21f5d | ||
| cac94374d9 | |||
|
|
b513e0555b | ||
|
|
de8c9cf035 | ||
|
|
7f8872cbe0 | ||
|
|
5626a34755 | ||
|
|
a98afdda01 | ||
| 4373092d44 | |||
| 7b5c61371e | |||
| 9bd997f699 | |||
| e2511a5c1f | |||
|
|
e8760a8937 | ||
|
|
7d4e097be8 | ||
|
|
2cba1ccfe0 | ||
| 06921e1dd1 | |||
|
|
68ba5f4b06 | ||
|
|
217c160ec7 | ||
|
|
ff010fa1f8 | ||
| f7145339b3 | |||
|
|
aa2b828386 | ||
|
|
547db3a76b | ||
|
|
4ac8b24524 | ||
|
|
d72cc207e1 | ||
|
|
477f5aa6e7 | ||
|
|
edb7232b17 | ||
|
|
44177ab7bd | ||
|
|
47b7ecd521 | ||
|
|
8a3902ec2b | ||
|
|
ea8f8e3895 | ||
|
|
f1af6601cc | ||
|
|
2d094404df | ||
|
|
8fdff1eb18 | ||
|
|
9170b5205c | ||
|
|
dccd2cdc36 | ||
|
|
6d01d47c2f | ||
|
|
280c80d89f | ||
|
|
92b8d4a1cd | ||
|
|
f77d109d00 | ||
|
|
823dd39a9b | ||
|
|
d5c12e81c3 | ||
|
|
04c00af92e | ||
|
|
a66a43dda8 | ||
| 77ff8ae372 | |||
|
|
011cf7951b | ||
|
|
7d480b9e2c | ||
|
|
cf27a67296 | ||
|
|
d6a4595bb8 | ||
|
|
f202818b55 | ||
|
|
3f2e4d6640 | ||
|
|
90b483e4ea | ||
| 434c7d5f4a | |||
|
|
a4eb840589 | ||
|
|
e80806ef9d | ||
|
|
c447c33b7a | ||
|
|
bcb524df52 | ||
| b64bb66119 | |||
|
|
118edb9dea | ||
|
|
b43ceb9f7e | ||
|
|
6c0d00dc39 | ||
|
|
be847c16b8 | ||
| 02a680f8cc | |||
|
|
dae8127ac8 | ||
|
|
0635caab65 | ||
| 1530ac3911 | |||
| 3f80467180 | |||
|
|
4b4e587a89 | ||
|
|
02c3846031 | ||
|
|
a8a01b3034 | ||
|
|
98c3a2c7f8 |
Submodule LightlessAPI updated: 44fbe10458...0170ac377d
210
LightlessSync/Changelog/changelog.yaml
Normal file
210
LightlessSync/Changelog/changelog.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
tagline: "Lightless Sync v1.12.4"
|
||||
subline: "Bugfixes and various improvements across Lightless"
|
||||
changelog:
|
||||
- name: "v1.12.4"
|
||||
tagline: "Preparation for future features"
|
||||
date: "November 11th 2025"
|
||||
# be sure to set this every new version
|
||||
isCurrent: true
|
||||
versions:
|
||||
- number: "Syncshells"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added a pause button for syncshells in grouped folders"
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed download notifications getting stuck at times"
|
||||
- "Added more offset positions for the notifications"
|
||||
- number: "Lightfinder"
|
||||
icon: ""
|
||||
items:
|
||||
- "Pair button will now show up when you're not directly paired. ie. You are technically paired in a syncshell, but you may not be directly paired'"
|
||||
- "Fixed a problem where the number of LightFinder users were cached, displaying the wrong information"
|
||||
- "When LightFinder is enabled, if you hover over the number of users, it will show you how many users are also using LightFinder in your area"
|
||||
-
|
||||
- number: "Bugfixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added even more checks to nameplate handler to help not only us debug, but also other plugins writing to the plate"
|
||||
- number: "Miscellaneous Changes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Default Linux to Websockets"
|
||||
- "Revised Brio warning"
|
||||
- "Added /lightless command to open Lightless UI (you can still use /light)"
|
||||
- "Initial groundwork for future features"
|
||||
- name: "v1.12.3"
|
||||
tagline: "LightSpeed, Welcome Screen, and More!"
|
||||
date: "October 15th 2025"
|
||||
versions:
|
||||
- number: "LightSpeed"
|
||||
icon: ""
|
||||
items:
|
||||
- "New way to download that will download mods directly from the file server"
|
||||
- "LightSpeed is in BETA and should be faster than the batch downloading"
|
||||
- number: "Welcome Screen + Additional Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "New in-game Patch Notes window."
|
||||
- "Credits section to thank contributors and supporters."
|
||||
- "Patch notes only show after updates, not during first-time setup."
|
||||
- "Syncshell Rework stared: Profiles have been added (more features using this will come later)."
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "More customizable notification options."
|
||||
- "Perfomance limiter shows as notifications."
|
||||
- "All notifications can be configured or disabled in Settings → Notifications."
|
||||
- "Cleaning up notifications implementation"
|
||||
- number: "Bugfixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added more safety checks to nameplates"
|
||||
- "Removed a line in SyncshellUI potentially causing NullPointers"
|
||||
- "Additional safety checks in PlayerData.Factory"
|
||||
- name: "v1.12.2"
|
||||
tagline: "LightFinder fixes, Notifications overhaul"
|
||||
date: "October 12th 2025"
|
||||
versions:
|
||||
- number: "LightFinder"
|
||||
icon: ""
|
||||
items:
|
||||
- "Server-side improvements for LightFinder functionality."
|
||||
- "Command changed from '/light lightfinder' to '/light finder'."
|
||||
- "Option to enable LightFinder on connection (opt-in, refreshes every 3 hours)."
|
||||
- "LightFinder indicator can now be shown on the server info bar."
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "Completely reworked notification system with new UI."
|
||||
- "Pair requests now show as notifications."
|
||||
- "Download progress shows as notifications."
|
||||
- "Customizable notification sounds, size, position, and duration."
|
||||
- "All notifications can be configured or disabled in Settings → Notifications."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed nameplate alignment issues with LightFinder and icons."
|
||||
- "Icons now properly apply instead of swapping on choice."
|
||||
- "Updated Discord URL."
|
||||
- "File cache logic improvements."
|
||||
|
||||
- name: "v1.12.1"
|
||||
tagline: "LightFinder customization and download limiter"
|
||||
date: "October 8th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "LightFinder text can be modified to an icon with customizable positioning."
|
||||
- "Option to hide your own indicator or paired player indicators."
|
||||
- "Pair Download Limiter: Limit simultaneous downloads to 1-6 users to reduce network strain."
|
||||
- "Added '/light lightfinder' command to open LightFinder UI."
|
||||
- number: "Improvements"
|
||||
icon: ""
|
||||
items:
|
||||
- "Right-click menu option for Send Pair Request can be disabled."
|
||||
- "Syncshell finder improvements."
|
||||
- "Download limiter settings available in Settings → Transfers."
|
||||
|
||||
- name: "v1.12.0"
|
||||
tagline: "LightFinder - Major feature release"
|
||||
date: "October 5th 2025"
|
||||
versions:
|
||||
- number: "Major Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Introduced LightFinder: Optional feature inspired by FFXIV's Party Finder."
|
||||
- "Find fellow Lightless users and advertise your Syncshell to others."
|
||||
- "When enabled, you're visible to other LightFinder users for 3 hours."
|
||||
- "LightFinder tag displays above your nameplate when active."
|
||||
- "Receive pair requests directly in UI without exchanging UIDs."
|
||||
- "Syncshell Finder allows joining indexed Syncshells."
|
||||
- "[L] Send Pair Request added to player context menus."
|
||||
- number: "Vanity Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Supporters can now customize their name color in the Lightless UI."
|
||||
- "Color changes visible to all users."
|
||||
- number: "General Improvements"
|
||||
icon: ""
|
||||
items:
|
||||
- "Pairing nameplate color override can now override FC tags."
|
||||
- "Added .kdb as whitelisted filetype for uploads."
|
||||
- "Various UI fixes, updates, and improvements."
|
||||
|
||||
- name: "v1.11.12"
|
||||
tagline: "Syncshell grouping and performance options"
|
||||
date: "September 16th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Ability to show grouped syncshells in main UI/all syncshells (default ON)."
|
||||
- "Transfer ownership button available in Admin Panel user list."
|
||||
- "Self-threshold warning now opens character analysis screen when clicked."
|
||||
- number: "Performance"
|
||||
icon: ""
|
||||
items:
|
||||
- "Auto-pause combat and auto-pause performance are now optional settings."
|
||||
- "Both options are auto-enabled by default - disable at your own risk."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Reworked file caching to reduce errors for some users."
|
||||
- "Fixed bug where exiting PvP could desync some users."
|
||||
|
||||
- name: "v1.11.9"
|
||||
tagline: "File cache improvements"
|
||||
date: "September 13th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Identified and fixed potential file cache problems."
|
||||
- "Improved cache error handling and stability."
|
||||
|
||||
- name: "v1.11.8"
|
||||
tagline: "Hotfix - UI and exception handling"
|
||||
date: "September 12th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Attempted fix for NullReferenceException spam."
|
||||
- "Fixed additional UI edge cases preventing loading for some users."
|
||||
- "Fixed color bar UI issues."
|
||||
|
||||
- name: "v1.11.7"
|
||||
tagline: "Hotfix - UI loading and warnings"
|
||||
date: "September 12th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed UI not loading for some users."
|
||||
- "Self warnings now behind 'Warn on loading in players exceeding performance thresholds' setting."
|
||||
|
||||
- name: "v1.11.6"
|
||||
tagline: "Admin panel rework and new features"
|
||||
date: "September 11th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Reworked Syncshell Admin Page with improved styling."
|
||||
- "Right-click on Server Top Bar button to disconnect from Lightless."
|
||||
- "Shift+Left click on Server Top Bar button to open settings."
|
||||
- "Added colors section in settings to change accent colors."
|
||||
- "Ability to pause syncing while in Instance/Duty."
|
||||
- "Functionality to create syncshell folders."
|
||||
- "Added self-threshold warning."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed owners being visible in moderator list view."
|
||||
- "Removed Pin/Remove/Ban buttons on Owners when viewing as moderator."
|
||||
- "Fixed nameplate bug in PvP."
|
||||
- "Added 1 or 3 day options for inactive check."
|
||||
- "Fixed bug where some users could not see their own syncshell folders."
|
||||
68
LightlessSync/Changelog/credits.yaml
Normal file
68
LightlessSync/Changelog/credits.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
credits:
|
||||
- category: "Development Team"
|
||||
items:
|
||||
- name: "Abel"
|
||||
role: "Developer"
|
||||
- name: "Cake"
|
||||
role: "Developer"
|
||||
- name: "Celine"
|
||||
role: "Developer"
|
||||
- name: "Choco"
|
||||
role: "Developer"
|
||||
- name: "Kenny"
|
||||
role: "Developer"
|
||||
- name: "Zura"
|
||||
role: "Developer"
|
||||
- name: "Additional Contributors"
|
||||
role: "Community Contributors & Bug Reporters"
|
||||
|
||||
- category: "Moderation Team"
|
||||
items:
|
||||
- name: "Crow"
|
||||
role: "Moderator"
|
||||
- name: "Faith"
|
||||
role: "Moderator"
|
||||
- name: "Kiwiwiwi"
|
||||
role: "Moderator"
|
||||
- name: "Kruwu"
|
||||
role: "Moderator"
|
||||
- name: "Lexi"
|
||||
role: "Moderator"
|
||||
- name: "Maya"
|
||||
role: "Moderator"
|
||||
- name: "Metaknight"
|
||||
role: "Moderator"
|
||||
- name: "Minmoose"
|
||||
role: "Moderator"
|
||||
- name: "Nihal"
|
||||
role: "Moderator"
|
||||
- name: "Tani"
|
||||
role: "Moderator"
|
||||
|
||||
- category: "Plugin Integration & IPC Support"
|
||||
items:
|
||||
- name: "Penumbra Team"
|
||||
role: "Mod framework integration"
|
||||
- name: "Glamourer Team"
|
||||
role: "Customization system integration"
|
||||
- name: "Customize+ Team"
|
||||
role: "Body scaling integration"
|
||||
- name: "Simple Heels Team"
|
||||
role: "Height offset integration"
|
||||
- name: "Honorific Team"
|
||||
role: "Title system integration"
|
||||
- name: "Glyceri"
|
||||
role: "Moodles - Status effect integration"
|
||||
- name: "Glyceri"
|
||||
role: "PetNicknames - Pet naming integration"
|
||||
- name: "Minmoose"
|
||||
role: "Brio - GPose enhancement integration"
|
||||
|
||||
- category: "Special Thanks"
|
||||
items:
|
||||
- name: "Dalamud & XIVLauncher Teams"
|
||||
role: "Plugin framework and infrastructure"
|
||||
- name: "Community Supporters"
|
||||
role: "Testing, feedback, and financial support"
|
||||
- name: "Beta Testers"
|
||||
role: "Early testing and bug reporting"
|
||||
@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public bool StorageisNTFS { get; private set; } = false;
|
||||
|
||||
public bool StorageIsBtrfs { get ; private set; } = false;
|
||||
|
||||
public void StartLightlessWatcher(string? lightlessPath)
|
||||
{
|
||||
LightlessWatcher?.Dispose();
|
||||
@@ -124,10 +126,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
|
||||
return;
|
||||
}
|
||||
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
|
||||
|
||||
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
|
||||
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
if (fsType == FileSystemHelper.FilesystemType.NTFS)
|
||||
{
|
||||
StorageisNTFS = true;
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
}
|
||||
|
||||
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
|
||||
{
|
||||
StorageIsBtrfs = true;
|
||||
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
|
||||
}
|
||||
|
||||
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
|
||||
LightlessWatcher = new()
|
||||
@@ -392,51 +403,94 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public void RecalculateFileCacheSize(CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
|
||||
!Directory.Exists(_configService.Current.CacheFolder))
|
||||
{
|
||||
FileCacheSize = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
FileCacheSize = -1;
|
||||
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
|
||||
bool isWine = _dalamudUtil?.IsWine ?? false;
|
||||
|
||||
try
|
||||
{
|
||||
FileCacheDriveFree = di.AvailableFreeSpace;
|
||||
var drive = DriveInfo.GetDrives()
|
||||
.FirstOrDefault(d => _configService.Current.CacheFolder
|
||||
.StartsWith(d.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (drive != null)
|
||||
FileCacheDriveFree = drive.AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
|
||||
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f))
|
||||
.OrderBy(f => f.LastAccessTime).ToList();
|
||||
FileCacheSize = files
|
||||
.Sum(f =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderBy(f => f.LastAccessTime)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
long size = 0;
|
||||
|
||||
if (!isWine)
|
||||
{
|
||||
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
|
||||
try
|
||||
{
|
||||
size = _fileCompactor.GetFileSizeOnDisk(f);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
|
||||
size = f.Length;
|
||||
}
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
size = f.Length;
|
||||
}
|
||||
});
|
||||
|
||||
totalSize += size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
FileCacheSize = totalSize;
|
||||
|
||||
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
|
||||
|
||||
if (FileCacheSize < maxCacheInBytes) return;
|
||||
if (FileCacheSize < maxCacheInBytes)
|
||||
return;
|
||||
|
||||
var maxCacheBuffer = maxCacheInBytes * 0.05d;
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
|
||||
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
|
||||
{
|
||||
var oldestFile = files[0];
|
||||
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
|
||||
File.Delete(oldestFile.FullName);
|
||||
files.Remove(oldestFile);
|
||||
|
||||
try
|
||||
{
|
||||
long fileSize = oldestFile.Length;
|
||||
File.Delete(oldestFile.FullName);
|
||||
FileCacheSize -= fileSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
|
||||
}
|
||||
|
||||
files.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,44 +698,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
// scan new files
|
||||
if (allScannedFiles.Any(c => !c.Value))
|
||||
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
|
||||
foreach (var cachePath in newFiles)
|
||||
{
|
||||
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
|
||||
new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = threadCount,
|
||||
CancellationToken = ct
|
||||
}, (cachePath) =>
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
if (ct.IsCancellationRequested) break;
|
||||
ProcessOne(cachePath);
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
void ProcessOne(string? cachePath)
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
|
||||
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
});
|
||||
|
||||
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (IOException ioex)
|
||||
{
|
||||
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug("Scan complete");
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
private readonly Lock _fileWriteLock = new();
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<FileCacheManager> _logger;
|
||||
private bool _csvHeaderEnsured;
|
||||
public string CacheFolder => _configService.Current.CacheFolder;
|
||||
|
||||
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
|
||||
@@ -202,42 +203,72 @@ public sealed class FileCacheManager : IHostedService
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
|
||||
public async Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
_logger.LogInformation("Validating local storage");
|
||||
var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList();
|
||||
List<FileCacheEntity> brokenEntities = [];
|
||||
int i = 0;
|
||||
foreach (var fileCache in cacheEntries)
|
||||
|
||||
var cacheEntries = _fileCaches.Values
|
||||
.SelectMany(v => v.Values)
|
||||
.Where(v => v.IsCacheEntry)
|
||||
.ToList();
|
||||
|
||||
int total = cacheEntries.Count;
|
||||
int processed = 0;
|
||||
var brokenEntities = new ConcurrentBag<FileCacheEntity>();
|
||||
|
||||
_logger.LogInformation("Checking {count} cache entries...", total);
|
||||
|
||||
await Parallel.ForEachAsync(cacheEntries, new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (fileCache, token) =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
|
||||
|
||||
progress.Report((i, cacheEntries.Count, fileCache));
|
||||
i++;
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
|
||||
int current = Interlocked.Increment(ref processed);
|
||||
if (current % 10 == 0)
|
||||
progress.Report((current, total, fileCache));
|
||||
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
string computedHash;
|
||||
try
|
||||
{
|
||||
computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
|
||||
_logger.LogInformation(
|
||||
"Hash mismatch: {file} (got {computedHash}, expected {expected})",
|
||||
fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
|
||||
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
|
||||
_logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
foreach (var brokenEntity in brokenEntities)
|
||||
{
|
||||
@@ -249,12 +280,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
|
||||
_logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath);
|
||||
}
|
||||
}
|
||||
|
||||
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
return Task.FromResult(brokenEntities);
|
||||
_logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count);
|
||||
|
||||
return [.. brokenEntities];
|
||||
}
|
||||
|
||||
public string GetCacheFilePath(string hash, string extension)
|
||||
@@ -462,6 +495,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
string[] existingLines = File.ReadAllLines(_csvPath);
|
||||
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
|
||||
{
|
||||
_csvHeaderEnsured = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,6 +515,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
|
||||
File.WriteAllText(_csvPath, rebuilt.ToString());
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void EnsureCsvHeaderLockedCached()
|
||||
{
|
||||
if (_csvHeaderEnsured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCsvHeaderLocked();
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void BackupUnsupportedCache(string suffix)
|
||||
@@ -540,10 +586,11 @@ public sealed class FileCacheManager : IHostedService
|
||||
if (!File.Exists(_csvPath))
|
||||
{
|
||||
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureCsvHeaderLocked();
|
||||
EnsureCsvHeaderLockedCached();
|
||||
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool ShowUploading { get; set; } = true;
|
||||
public bool ShowUploadingBigText { get; set; } = true;
|
||||
public bool ShowVisibleUsersSeparately { get; set; } = true;
|
||||
public bool EnableDirectDownloads { get; set; } = true;
|
||||
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
|
||||
public int TransferBarsHeight { get; set; } = 12;
|
||||
public bool TransferBarsShowText { get; set; } = true;
|
||||
@@ -86,6 +87,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
|
||||
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
|
||||
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
|
||||
// Basic Settings
|
||||
public float NotificationOpacity { get; set; } = 0.95f;
|
||||
@@ -95,6 +97,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool ShowNotificationTimestamp { get; set; } = false;
|
||||
|
||||
// Position & Layout
|
||||
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
|
||||
public int NotificationOffsetY { get; set; } = 50;
|
||||
public int NotificationOffsetX { get; set; } = 0;
|
||||
public float NotificationWidth { get; set; } = 350f;
|
||||
@@ -102,6 +105,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
|
||||
// Animation & Effects
|
||||
public float NotificationAnimationSpeed { get; set; } = 10f;
|
||||
public float NotificationSlideSpeed { get; set; } = 10f;
|
||||
public float NotificationAccentBarWidth { get; set; } = 3f;
|
||||
|
||||
// Duration per Type
|
||||
@@ -109,17 +113,20 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public int WarningNotificationDurationSeconds { get; set; } = 15;
|
||||
public int ErrorNotificationDurationSeconds { get; set; } = 20;
|
||||
public int PairRequestDurationSeconds { get; set; } = 180;
|
||||
public int DownloadNotificationDurationSeconds { get; set; } = 300;
|
||||
public int DownloadNotificationDurationSeconds { get; set; } = 30;
|
||||
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
|
||||
public uint CustomInfoSoundId { get; set; } = 2; // Se2
|
||||
public uint CustomWarningSoundId { get; set; } = 16; // Se15
|
||||
public uint CustomErrorSoundId { get; set; } = 16; // Se15
|
||||
public uint PairRequestSoundId { get; set; } = 5; // Se5
|
||||
public uint DownloadSoundId { get; set; } = 15; // Se14
|
||||
public uint PerformanceSoundId { get; set; } = 16; // Se15
|
||||
public bool DisableInfoSound { get; set; } = true;
|
||||
public bool DisableWarningSound { get; set; } = true;
|
||||
public bool DisableErrorSound { get; set; } = true;
|
||||
public bool DisablePairRequestSound { get; set; } = true;
|
||||
public bool DisableDownloadSound { get; set; } = true;
|
||||
public bool DisablePerformanceSound { get; set; } = true;
|
||||
public bool ShowPerformanceNotificationActions { get; set; } = true;
|
||||
public bool ShowPairRequestNotificationActions { get; set; } = true;
|
||||
public bool UseFocusTarget { get; set; } = false;
|
||||
public bool overrideFriendColor { get; set; } = false;
|
||||
public bool overridePartyColor { get; set; } = false;
|
||||
@@ -140,4 +147,5 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
|
||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,12 @@ public enum NotificationType
|
||||
Warning,
|
||||
Error,
|
||||
PairRequest,
|
||||
Download
|
||||
Download,
|
||||
Performance
|
||||
}
|
||||
|
||||
public enum NotificationCorner
|
||||
{
|
||||
Right,
|
||||
Left
|
||||
}
|
||||
@@ -13,5 +13,4 @@ public class ServerStorage
|
||||
public bool UseOAuth2 { get; set; } = false;
|
||||
public string? OAuthToken { get; set; } = null;
|
||||
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
|
||||
public bool ForceWebSockets { get; set; } = false;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using LightlessSync.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LightlessSync;
|
||||
@@ -101,7 +102,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
UIColors.Initialize(_lightlessConfigService);
|
||||
Mediator.StartQueueProcessing();
|
||||
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CheckVersion()
|
||||
{
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
|
||||
var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty;
|
||||
Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion);
|
||||
Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup());
|
||||
Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig());
|
||||
// Show update notes if version has changed and user has valid setup
|
||||
|
||||
if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) &&
|
||||
_lightlessConfigService.Current.HasValidSetup() &&
|
||||
_serverConfigurationManager.HasValidConfig())
|
||||
{
|
||||
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DalamudUtilOnLogIn()
|
||||
{
|
||||
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
|
||||
CheckVersion();
|
||||
|
||||
#if !DEBUG
|
||||
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
|
||||
<PropertyGroup>
|
||||
<Authors></Authors>
|
||||
<Company></Company>
|
||||
<Version>1.12.2</Version>
|
||||
<Version>1.12.3.1</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
@@ -46,6 +46,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -64,6 +65,8 @@
|
||||
<None Update="images\icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||
<EmbeddedResource Include="Localization\de.json" />
|
||||
<EmbeddedResource Include="Localization\fr.json" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,21 +12,38 @@ public class FileDownloadManagerFactory
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly FileCompactor _fileCompactor;
|
||||
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly LightlessConfigService _configService;
|
||||
|
||||
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator,
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
|
||||
public FileDownloadManagerFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
LightlessMediator lightlessMediator,
|
||||
FileTransferOrchestrator fileTransferOrchestrator,
|
||||
FileCacheManager fileCacheManager,
|
||||
FileCompactor fileCompactor,
|
||||
PairProcessingLimiter pairProcessingLimiter,
|
||||
LightlessConfigService configService)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_fileTransferOrchestrator = fileTransferOrchestrator;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_fileCompactor = fileCompactor;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public FileDownloadManager Create()
|
||||
{
|
||||
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
|
||||
return new FileDownloadManager(
|
||||
_loggerFactory.CreateLogger<FileDownloadManager>(),
|
||||
_lightlessMediator,
|
||||
_fileTransferOrchestrator,
|
||||
_fileCacheManager,
|
||||
_fileCompactor,
|
||||
_pairProcessingLimiter,
|
||||
_configService);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,19 @@ public class PlayerDataFactory
|
||||
|
||||
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
{
|
||||
return ((Character*)playerPointer)->GameObject.DrawObject == null;
|
||||
if (playerPointer == IntPtr.Zero)
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
var gameObject = &character->GameObject;
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
|
||||
@@ -138,7 +138,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (_allClientPairs.TryGetValue(user, out var pair))
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(pair.UserData));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData));
|
||||
pair.MarkOffline();
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
|
||||
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
|
||||
var pair = _allClientPairs[dto.User];
|
||||
if (pair.HasCachedPlayer)
|
||||
@@ -254,7 +254,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused())
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
}
|
||||
|
||||
pair.UserPair.OtherPermissions = dto.Permissions;
|
||||
@@ -280,7 +280,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused())
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
}
|
||||
|
||||
pair.UserPair.OwnPermissions = dto.Permissions;
|
||||
|
||||
@@ -246,6 +246,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
|
||||
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
|
||||
@@ -254,9 +255,9 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUI>((s) =>
|
||||
new LightlessNotificationUI(
|
||||
s.GetRequiredService<ILogger<LightlessNotificationUI>>(),
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>((s) =>
|
||||
new LightlessNotificationUi(
|
||||
s.GetRequiredService<ILogger<LightlessNotificationUi>>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<LightlessConfigService>()));
|
||||
@@ -268,8 +269,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
|
||||
s.GetRequiredService<UiFactory>(),
|
||||
s.GetRequiredService<FileDialogManager>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<NotificationService>()));
|
||||
s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
|
||||
|
||||
@@ -28,7 +28,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
|
||||
private readonly CancellationTokenSource _cleanupCts = new();
|
||||
private Task? _cleanupTask;
|
||||
|
||||
private int _checkEveryFrames = 20;
|
||||
private readonly int _checkEveryFrames = 20;
|
||||
private int _frameCounter = 0;
|
||||
private int _lookupsThisFrame = 0;
|
||||
private const int MaxLookupsPerFrame = 30;
|
||||
@@ -221,6 +221,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
|
||||
}
|
||||
|
||||
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return [.. _broadcastCache.Where(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -140,6 +144,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
IsLightFinderAvailable = false;
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
|
||||
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -236,6 +241,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Auto-Enabled",
|
||||
"Your Lightfinder broadcast has been automatically enabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -391,9 +401,14 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
|
||||
public async void ToggleBroadcast()
|
||||
{
|
||||
|
||||
if (!IsLightFinderAvailable)
|
||||
{
|
||||
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Unavailable",
|
||||
"Lightfinder is not available on this server.",
|
||||
NotificationType.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -403,6 +418,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Cooldown",
|
||||
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
|
||||
NotificationType.Warning));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,10 +446,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
|
||||
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
|
||||
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Toggle Failed",
|
||||
$"Failed to toggle broadcast: {ex.Message}",
|
||||
NotificationType.Error));
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
@@ -493,6 +521,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
ShowBroadcastExpiredNotification();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -501,4 +530,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ShowBroadcastExpiredNotification()
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "broadcast_expired",
|
||||
Title = "Broadcast Expired",
|
||||
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(180),
|
||||
Actions = new List<LightlessNotificationAction>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "re_enable",
|
||||
Label = "Re-enable",
|
||||
Icon = FontAwesomeIcon.Plus,
|
||||
Color = UIColors.Get("PairBlue"),
|
||||
IsPrimary = true,
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Re-enabling broadcast from notification");
|
||||
ToggleBroadcast();
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "close",
|
||||
Label = "Close",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Broadcast expiration notification dismissed");
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ using LightlessSync.UI;
|
||||
using LightlessSync.Utils;
|
||||
using Lumina.Data.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
@@ -16,6 +20,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
private CancellationTokenSource? _analysisCts;
|
||||
private CancellationTokenSource _baseAnalysisCts = new();
|
||||
private string _lastDataHash = string.Empty;
|
||||
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
|
||||
|
||||
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
|
||||
: base(logger, mediator)
|
||||
@@ -34,6 +39,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
public bool IsAnalysisRunning => _analysisCts != null;
|
||||
public int TotalFiles { get; internal set; }
|
||||
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
|
||||
public CharacterAnalysisSummary LatestSummary => _latestSummary;
|
||||
|
||||
public void CancelAnalyze()
|
||||
{
|
||||
@@ -80,6 +86,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_analysisCts.CancelDispose();
|
||||
@@ -137,11 +145,39 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void RecalculateSummary()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
||||
|
||||
foreach (var (objectKind, entries) in LastAnalysis)
|
||||
{
|
||||
long totalTriangles = 0;
|
||||
long texOriginalBytes = 0;
|
||||
long texCompressedBytes = 0;
|
||||
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
totalTriangles += entry.Triangles;
|
||||
if (string.Equals(entry.FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
texOriginalBytes += entry.OriginalSize;
|
||||
texCompressedBytes += entry.CompressedSize;
|
||||
}
|
||||
}
|
||||
|
||||
builder[objectKind] = new CharacterAnalysisObjectSummary(entries.Count, totalTriangles, texOriginalBytes, texCompressedBytes);
|
||||
}
|
||||
|
||||
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
@@ -232,4 +268,24 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
|
||||
{
|
||||
public bool HasEntries => EntryCount > 0;
|
||||
}
|
||||
|
||||
public sealed class CharacterAnalysisSummary
|
||||
{
|
||||
public static CharacterAnalysisSummary Empty { get; } =
|
||||
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
|
||||
|
||||
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
|
||||
{
|
||||
Objects = objects;
|
||||
}
|
||||
|
||||
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
|
||||
|
||||
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
private const string _commandName = "/light";
|
||||
private const string _longName = "/lightless";
|
||||
private const string _shortName = "/light";
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ICommandManager _commandManager;
|
||||
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
|
||||
_apiController = apiController;
|
||||
_mediator = mediator;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
|
||||
_commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = $"\u2191;"
|
||||
});
|
||||
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
|
||||
"Additionally possible commands:" + Environment.NewLine +
|
||||
@@ -49,7 +54,8 @@ public sealed class CommandManagerService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_commandManager.RemoveHandler(_commandName);
|
||||
_commandManager.RemoveHandler(_longName);
|
||||
_commandManager.RemoveHandler(_shortName);
|
||||
}
|
||||
|
||||
private void OnCommand(string command, string args)
|
||||
|
||||
228
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal file
228
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace LightlessSync.Services.Compactor
|
||||
{
|
||||
/// <summary>
|
||||
/// This batch service is made for the File Frag command, because of each file needing to use this command.
|
||||
/// It's better to combine into one big command in batches then doing each command on each compressed call.
|
||||
/// </summary>
|
||||
public sealed partial class BatchFilefragService : IDisposable
|
||||
{
|
||||
private readonly Channel<(string path, TaskCompletionSource<bool> tcs)> _ch;
|
||||
private readonly Task _worker;
|
||||
private readonly bool _useShell;
|
||||
private readonly ILogger _log;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _flushDelay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable<string> args, string? workingDir, int timeoutMs);
|
||||
private readonly RunDirect _runDirect;
|
||||
|
||||
public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs);
|
||||
private readonly RunShell _runShell;
|
||||
|
||||
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null)
|
||||
{
|
||||
_useShell = useShell;
|
||||
_log = log;
|
||||
_batchSize = Math.Max(8, batchSize);
|
||||
_flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs));
|
||||
_ch = Channel.CreateUnbounded<(string, TaskCompletionSource<bool>)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
// require runners to be setup, wouldnt start otherwise
|
||||
if (runDirect is null || runShell is null)
|
||||
throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor");
|
||||
_runDirect = runDirect;
|
||||
_runShell = runShell;
|
||||
|
||||
_worker = Task.Run(ProcessAsync, _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the file is compressed using Btrfs using tasks
|
||||
/// </summary>
|
||||
/// <param name="linuxPath">Linux/Wine path given for the file.</param>
|
||||
/// <param name="ct">Cancellation Token</param>
|
||||
/// <returns>If it was compressed or not</returns>
|
||||
public Task<bool> IsCompressedAsync(string linuxPath, CancellationToken ct = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (!_ch.Writer.TryWrite((linuxPath, tcs)))
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
if (ct.CanBeCanceled)
|
||||
{
|
||||
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
_ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process the pending compression tasks asynchronously
|
||||
/// </summary>
|
||||
/// <returns>Task</returns>
|
||||
private async Task ProcessAsync()
|
||||
{
|
||||
var reader = _ch.Reader;
|
||||
var pending = new List<(string path, TaskCompletionSource<bool> tcs)>(_batchSize);
|
||||
|
||||
try
|
||||
{
|
||||
while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!reader.TryRead(out var first)) continue;
|
||||
pending.Add(first);
|
||||
|
||||
var flushAt = DateTime.UtcNow + _flushDelay;
|
||||
while (pending.Count < _batchSize && DateTime.UtcNow < flushAt)
|
||||
{
|
||||
if (reader.TryRead(out var item))
|
||||
{
|
||||
pending.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var map = RunBatch(pending.Select(p => p.path));
|
||||
foreach (var (path, tcs) in pending)
|
||||
{
|
||||
tcs.TrySetResult(map.TryGetValue(path, out var c) && c);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "filefrag batch failed. falling back to false");
|
||||
foreach (var (_, tcs) in pending)
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//Shutting down worker, exception called
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Running the batch of each file in the queue in one file frag command.
|
||||
/// </summary>
|
||||
/// <param name="paths">Paths that are needed for the command building for the batch return</param>
|
||||
/// <returns>Path of the file and if it went correctly</returns>
|
||||
/// <exception cref="InvalidOperationException">Failing to start filefrag on the system if this exception is found</exception>
|
||||
private Dictionary<string, bool> RunBatch(IEnumerable<string> paths)
|
||||
{
|
||||
var list = paths.Distinct(StringComparer.Ordinal).ToList();
|
||||
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
|
||||
|
||||
(bool ok, string stdout, string stderr, int code) res;
|
||||
|
||||
if (_useShell)
|
||||
{
|
||||
var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle));
|
||||
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
|
||||
}
|
||||
else
|
||||
{
|
||||
var args = new List<string> { "-v" };
|
||||
foreach (var path in list)
|
||||
{
|
||||
args.Add(' ' + path);
|
||||
}
|
||||
|
||||
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(res.stderr))
|
||||
_log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim());
|
||||
|
||||
ParseFilefrag(res.stdout, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsing the string given from the File Frag command into mapping
|
||||
/// </summary>
|
||||
/// <param name="output">Output of the process from the File Frag</param>
|
||||
/// <param name="map">Mapping of the processed files</param>
|
||||
private static void ParseFilefrag(string output, Dictionary<string, bool> map)
|
||||
{
|
||||
var reHeaderColon = ColonRegex();
|
||||
var reHeaderSize = SizeRegex();
|
||||
|
||||
string? current = null;
|
||||
using var sr = new StringReader(output);
|
||||
for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine())
|
||||
{
|
||||
var m1 = reHeaderColon.Match(line);
|
||||
if (m1.Success) { current = m1.Groups[1].Value; continue; }
|
||||
|
||||
var m2 = reHeaderSize.Match(line);
|
||||
if (m2.Success) { current = m2.Groups[1].Value; continue; }
|
||||
|
||||
if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) &&
|
||||
line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current))
|
||||
{
|
||||
map[current] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
|
||||
|
||||
/// <summary>
|
||||
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
|
||||
/// </summary>
|
||||
/// <returns>Regex of the File Size</returns>
|
||||
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)]
|
||||
private static partial Regex SizeRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Regex on colons return on the Linux/Wine systems
|
||||
/// </summary>
|
||||
/// <returns>Regex of the colons in the given path</returns>
|
||||
[GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
|
||||
private static partial Regex ColonRegex();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ch.Writer.TryComplete();
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_worker.Wait(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore the catch in dispose
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ internal class ContextMenuService : IHostedService
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
return;
|
||||
|
||||
//Check if user is paired or is own.
|
||||
//Check if user is directly paired or is own.
|
||||
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
|
||||
return;
|
||||
|
||||
@@ -116,7 +116,7 @@ internal class ContextMenuService : IHostedService
|
||||
|
||||
args.AddMenuItem(new MenuItem
|
||||
{
|
||||
Name = "Send Pair Request",
|
||||
Name = "Send Direct Pair Request",
|
||||
PrefixChar = 'L',
|
||||
UseDefaultPrefix = false,
|
||||
PrefixColor = 708,
|
||||
@@ -159,7 +159,7 @@ internal class ContextMenuService : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
|
||||
6
LightlessSync/Services/LightlessGroupProfileData.cs
Normal file
6
LightlessSync/Services/LightlessGroupProfileData.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
|
||||
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));
|
||||
@@ -48,26 +48,30 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
|
||||
public record HonorificReadyMessage : MessageBase;
|
||||
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
|
||||
public record HaltScanMessage(string Source) : MessageBase;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record NotificationMessage
|
||||
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
|
||||
public record PerformanceNotificationMessage
|
||||
(string Title, string Message, UserData UserData, bool IsPaused, string PlayerName) : MessageBase;
|
||||
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
|
||||
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
|
||||
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
|
||||
public record ClearAllNotificationsMessage : MessageBase;
|
||||
public record CharacterDataAnalyzedMessage : MessageBase;
|
||||
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
|
||||
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
|
||||
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
|
||||
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
|
||||
public record UiToggleMessage(Type UiType) : MessageBase;
|
||||
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
|
||||
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase;
|
||||
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
|
||||
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
|
||||
public record CyclePauseMessage(UserData UserData) : MessageBase;
|
||||
public record PauseMessage(UserData UserData) : MessageBase;
|
||||
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
|
||||
@@ -104,7 +108,9 @@ public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase
|
||||
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
|
||||
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
||||
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
|
||||
public record PairRequestsUpdatedMessage : MessageBase;
|
||||
public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase;
|
||||
public record VisibilityChange : MessageBase;
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
@@ -1,5 +1,6 @@
|
||||
using Dalamud.Game.Addon.Lifecycle;
|
||||
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
@@ -15,8 +16,9 @@ using LightlessSync.UtilsEnum.Enum;
|
||||
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
@@ -32,10 +34,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
private readonly LightlessMediator _mediator;
|
||||
public LightlessMediator Mediator => _mediator;
|
||||
|
||||
private bool mEnabled = false;
|
||||
private bool _mEnabled = false;
|
||||
private bool _needsLabelRefresh = false;
|
||||
private AddonNamePlate* mpNameplateAddon = null;
|
||||
private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
|
||||
private AddonNamePlate* _mpNameplateAddon = null;
|
||||
private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
@@ -44,10 +46,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
internal const uint mNameplateNodeIDBase = 0x7D99D500;
|
||||
private const string DefaultLabelText = "LightFinder";
|
||||
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
|
||||
private const int ContainerOffsetX = 50;
|
||||
private const int _containerOffsetX = 50;
|
||||
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
|
||||
|
||||
private volatile HashSet<string> _activeBroadcastingCids = [];
|
||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||
|
||||
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
|
||||
{
|
||||
@@ -74,17 +76,17 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
DisableNameplate();
|
||||
DestroyNameplateNodes();
|
||||
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
|
||||
mpNameplateAddon = null;
|
||||
_mpNameplateAddon = null;
|
||||
}
|
||||
|
||||
internal void EnableNameplate()
|
||||
{
|
||||
if (!mEnabled)
|
||||
if (!_mEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
|
||||
mEnabled = true;
|
||||
_mEnabled = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -96,7 +98,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
|
||||
internal void DisableNameplate()
|
||||
{
|
||||
if (mEnabled)
|
||||
if (_mEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -107,24 +109,30 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
|
||||
}
|
||||
|
||||
mEnabled = false;
|
||||
_mEnabled = false;
|
||||
HideAllNameplateNodes();
|
||||
}
|
||||
}
|
||||
|
||||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
||||
{
|
||||
if (args.Addon.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
|
||||
return;
|
||||
}
|
||||
|
||||
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
||||
|
||||
if (mpNameplateAddon != pNameplateAddon)
|
||||
if (_mpNameplateAddon != pNameplateAddon)
|
||||
{
|
||||
for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null;
|
||||
for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
|
||||
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
mpNameplateAddon = pNameplateAddon;
|
||||
if (mpNameplateAddon != null) CreateNameplateNodes();
|
||||
_mpNameplateAddon = pNameplateAddon;
|
||||
if (_mpNameplateAddon != null) CreateNameplateNodes();
|
||||
}
|
||||
|
||||
UpdateNameplateNodes();
|
||||
@@ -138,7 +146,16 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
if (nameplateObject == null)
|
||||
continue;
|
||||
|
||||
var rootNode = nameplateObject.Value.RootComponentNode;
|
||||
if (rootNode == null || rootNode->Component == null)
|
||||
continue;
|
||||
|
||||
var pNameplateResNode = nameplateObject.Value.NameContainer;
|
||||
if (pNameplateResNode == null)
|
||||
continue;
|
||||
if (pNameplateResNode->ChildNode == null)
|
||||
continue;
|
||||
|
||||
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
|
||||
|
||||
if (pNewNode != null)
|
||||
@@ -148,24 +165,43 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
pNewNode->AtkResNode.NextSiblingNode = pLastChild;
|
||||
pNewNode->AtkResNode.ParentNode = pNameplateResNode;
|
||||
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
|
||||
nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
|
||||
rootNode->Component->UldManager.UpdateDrawNodeList();
|
||||
pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||
mTextNodes[i] = pNewNode;
|
||||
_mTextNodes[i] = pNewNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyNameplateNodes()
|
||||
{
|
||||
var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address;
|
||||
if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon)
|
||||
var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
|
||||
if (currentHandle.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
|
||||
if (_mpNameplateAddon == null)
|
||||
return;
|
||||
|
||||
if (_mpNameplateAddon != pCurrentNameplateAddon)
|
||||
{
|
||||
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
var pTextNode = mTextNodes[i];
|
||||
var pTextNode = _mTextNodes[i];
|
||||
var pNameplateNode = GetNameplateComponentNode(i);
|
||||
if (pTextNode != null && pNameplateNode != null)
|
||||
if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null))
|
||||
{
|
||||
_logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -175,7 +211,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
|
||||
pNameplateNode->Component->UldManager.UpdateDrawNodeList();
|
||||
pTextNode->AtkResNode.Destroy(true);
|
||||
mTextNodes[i] = null;
|
||||
_mTextNodes[i] = null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -192,7 +228,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
|
||||
private void HideAllNameplateNodes()
|
||||
{
|
||||
for (int i = 0; i < mTextNodes.Length; ++i)
|
||||
for (int i = 0; i < _mTextNodes.Length; ++i)
|
||||
{
|
||||
HideNameplateTextNode(i);
|
||||
}
|
||||
@@ -200,16 +236,62 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
|
||||
private void UpdateNameplateNodes()
|
||||
{
|
||||
var framework = Framework.Instance();
|
||||
var ui3DModule = framework->GetUIModule()->GetUI3DModule();
|
||||
var currentHandle = _gameGui.GetAddonByName("NamePlate");
|
||||
if (currentHandle.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentAddon = (AddonNamePlate*)currentHandle.Address;
|
||||
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
|
||||
{
|
||||
if (_mpNameplateAddon != null)
|
||||
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
var framework = Framework.Instance();
|
||||
if (framework == null)
|
||||
{
|
||||
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var uiModule = framework->GetUIModule();
|
||||
if (uiModule == null)
|
||||
{
|
||||
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ui3DModule = uiModule->GetUI3DModule();
|
||||
if (ui3DModule == null)
|
||||
{
|
||||
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var vec = ui3DModule->NamePlateObjectInfoPointers;
|
||||
if (vec.IsEmpty)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i)
|
||||
{
|
||||
var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value;
|
||||
var visibleUserIdsSnapshot = VisibleUserIds;
|
||||
|
||||
var safeCount = System.Math.Min(
|
||||
ui3DModule->NamePlateObjectInfoCount,
|
||||
vec.Length
|
||||
);
|
||||
|
||||
for (int i = 0; i < safeCount; ++i)
|
||||
{
|
||||
var config = _configService.Current;
|
||||
|
||||
var objectInfoPtr = vec[i];
|
||||
if (objectInfoPtr == null)
|
||||
continue;
|
||||
|
||||
var objectInfo = objectInfoPtr.Value;
|
||||
if (objectInfo == null || objectInfo->GameObject == null)
|
||||
continue;
|
||||
|
||||
@@ -217,62 +299,68 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
|
||||
continue;
|
||||
|
||||
var pNode = mTextNodes[nameplateIndex];
|
||||
var pNode = _mTextNodes[nameplateIndex];
|
||||
if (pNode == null)
|
||||
continue;
|
||||
|
||||
if (mpNameplateAddon == null)
|
||||
var gameObject = objectInfo->GameObject;
|
||||
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject);
|
||||
}
|
||||
|
||||
// CID gating
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId))
|
||||
var local = _clientState.LocalPlayer;
|
||||
if (!config.LightfinderLabelShowOwn && local != null &&
|
||||
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId()))
|
||||
var hidePaired = !config.LightfinderLabelShowPaired;
|
||||
|
||||
var goId = (ulong)gameObject->GetGameObjectId();
|
||||
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
|
||||
nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
|
||||
|
||||
var pNameplateIconNode = nameplateObject.MarkerIcon;
|
||||
var pNameplateResNode = nameplateObject.NameContainer;
|
||||
var pNameplateTextNode = nameplateObject.NameText;
|
||||
bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()) || _configService.Current.LightfinderLabelShowHidden;
|
||||
pNode->AtkResNode.ToggleVisibility(IsVisible);
|
||||
|
||||
if (nameplateObject.RootComponentNode == null ||
|
||||
nameplateObject.NameContainer == null ||
|
||||
nameplateObject.NameText == null)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
|
||||
var root = nameplateObject.RootComponentNode;
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
var nameText = nameplateObject.NameText;
|
||||
var marker = nameplateObject.MarkerIcon;
|
||||
|
||||
if (nameContainer == null || nameText == null)
|
||||
if (root == null || root->Component == null || nameContainer == null || nameText == null)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
|
||||
bool isVisible =
|
||||
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
||||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
||||
config.LightfinderLabelShowHidden;
|
||||
|
||||
pNode->AtkResNode.ToggleVisibility(isVisible);
|
||||
if (!isVisible)
|
||||
continue;
|
||||
|
||||
var labelColor = UIColors.Get("Lightfinder");
|
||||
var edgeColor = UIColors.Get("LightfinderEdge");
|
||||
var config = _configService.Current;
|
||||
|
||||
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||
@@ -431,7 +519,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
positionY += config.LightfinderLabelOffsetY;
|
||||
|
||||
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
|
||||
pNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
||||
|
||||
pNode->AtkResNode.Color.A = 255;
|
||||
|
||||
@@ -539,7 +627,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
}
|
||||
private void HideNameplateTextNode(int i)
|
||||
{
|
||||
var pNode = mTextNodes[i];
|
||||
var pNode = _mTextNodes[i];
|
||||
if (pNode != null)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
@@ -549,10 +637,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
|
||||
{
|
||||
if (i < AddonNamePlate.NumNamePlateObjects &&
|
||||
mpNameplateAddon != null &&
|
||||
mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
|
||||
_mpNameplateAddon != null &&
|
||||
_mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
|
||||
{
|
||||
return mpNameplateAddon->NamePlateObjectArray[i];
|
||||
return _mpNameplateAddon->NamePlateObjectArray[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -565,10 +653,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
var nameplateObject = GetNameplateObject(i);
|
||||
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
|
||||
public void FlagRefresh()
|
||||
{
|
||||
_needsLabelRefresh = true;
|
||||
@@ -585,18 +675,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
|
||||
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||
{
|
||||
var newSet = cids.ToHashSet();
|
||||
|
||||
var changed = !_activeBroadcastingCids.SetEquals(newSet);
|
||||
if (!changed)
|
||||
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
|
||||
return;
|
||||
|
||||
_activeBroadcastingCids.Clear();
|
||||
foreach (var cid in newSet)
|
||||
_activeBroadcastingCids.Add(cid);
|
||||
|
||||
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids));
|
||||
|
||||
_activeBroadcastingCids = newSet;
|
||||
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
|
||||
FlagRefresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ using LightlessSync.UI.Models;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using LightlessSync.API.Data;
|
||||
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private readonly ILogger<NotificationService> _logger;
|
||||
@@ -43,7 +45,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
|
||||
Mediator.Subscribe<PairRequestReceivedMessage>(this, HandlePairRequestReceived);
|
||||
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
|
||||
Mediator.Subscribe<PairDownloadStatusMessage>(this, HandlePairDownloadStatus);
|
||||
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -107,23 +112,42 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
|
||||
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
var location = GetNotificationLocation(NotificationType.PairRequest);
|
||||
|
||||
// Show in chat if configured
|
||||
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
|
||||
{
|
||||
Id = $"pair_request_{senderId}",
|
||||
Title = "Pair Request Received",
|
||||
Message = $"{senderName} wants to directly pair with you.",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||
SoundEffectId = GetPairRequestSoundId(),
|
||||
Actions = CreatePairRequestActions(onAccept, onDecline)
|
||||
};
|
||||
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
|
||||
}
|
||||
|
||||
// Show Lightless notification if configured and action buttons are enabled
|
||||
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
|
||||
&& _configService.Current.UseLightlessNotifications
|
||||
&& _configService.Current.ShowPairRequestNotificationActions)
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = $"pair_request_{senderId}",
|
||||
Title = "Pair Request Received",
|
||||
Message = $"{senderName} wants to directly pair with you.",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||
SoundEffectId = GetPairRequestSoundId(),
|
||||
Actions = CreatePairRequestActions(onAccept, onDecline)
|
||||
};
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
|
||||
{
|
||||
// Fall back to regular notification without action buttons
|
||||
HandleNotificationMessage(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
|
||||
}
|
||||
}
|
||||
|
||||
private uint? GetPairRequestSoundId() =>
|
||||
@@ -271,33 +295,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
return actions;
|
||||
}
|
||||
|
||||
public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus,
|
||||
int queueWaiting = 0)
|
||||
{
|
||||
var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList();
|
||||
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f;
|
||||
var message = BuildPairDownloadMessage(userDownloads, queueWaiting);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "pair_download_progress",
|
||||
Title = "Downloading Pair Data",
|
||||
Message = message,
|
||||
Type = NotificationType.Download,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||
ShowProgress = true,
|
||||
Progress = totalProgress
|
||||
};
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
|
||||
if (AreAllDownloadsCompleted(userDownloads))
|
||||
{
|
||||
DismissPairDownloadNotification();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads,
|
||||
private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads,
|
||||
int queueWaiting)
|
||||
{
|
||||
var messageParts = new List<string>();
|
||||
@@ -309,7 +308,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
|
||||
if (userDownloads.Count > 0)
|
||||
{
|
||||
var completedCount = userDownloads.Count(x => x.progress >= 1.0f);
|
||||
var completedCount = userDownloads.Count(x => x.Progress >= 1.0f);
|
||||
messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
|
||||
}
|
||||
|
||||
@@ -322,33 +321,27 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
return string.Join("\n", messageParts);
|
||||
}
|
||||
|
||||
private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads)
|
||||
private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads)
|
||||
{
|
||||
var activeDownloads = userDownloads
|
||||
.Where(x => x.progress < 1.0f)
|
||||
.Where(x => x.Progress < 1.0f)
|
||||
.Take(_configService.Current.MaxConcurrentPairApplications);
|
||||
|
||||
if (!activeDownloads.Any()) return string.Empty;
|
||||
|
||||
return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}"));
|
||||
return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
|
||||
}
|
||||
|
||||
private string FormatDownloadStatus((string playerName, float progress, string status) download) =>
|
||||
download.status switch
|
||||
private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) =>
|
||||
download.Status switch
|
||||
{
|
||||
"downloading" => $"{download.progress:P0}",
|
||||
"downloading" => $"{download.Progress:P0}",
|
||||
"decompressing" => "decompressing",
|
||||
"queued" => "queued",
|
||||
"waiting" => "waiting for slot",
|
||||
_ => download.status
|
||||
_ => download.Status
|
||||
};
|
||||
|
||||
private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) =>
|
||||
userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f);
|
||||
|
||||
public void DismissPairDownloadNotification() =>
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
|
||||
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
|
||||
@@ -356,6 +349,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
|
||||
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
|
||||
_ => TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
@@ -371,7 +365,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
NotificationType.Info => _configService.Current.DisableInfoSound,
|
||||
NotificationType.Warning => _configService.Current.DisableWarningSound,
|
||||
NotificationType.Error => _configService.Current.DisableErrorSound,
|
||||
NotificationType.Download => _configService.Current.DisableDownloadSound,
|
||||
NotificationType.Performance => _configService.Current.DisablePerformanceSound,
|
||||
NotificationType.Download => true, // Download sounds always disabled
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -380,7 +375,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
NotificationType.Info => _configService.Current.CustomInfoSoundId,
|
||||
NotificationType.Warning => _configService.Current.CustomWarningSoundId,
|
||||
NotificationType.Error => _configService.Current.CustomErrorSoundId,
|
||||
NotificationType.Download => _configService.Current.DownloadSoundId,
|
||||
NotificationType.Performance => _configService.Current.PerformanceSoundId,
|
||||
_ => NotificationSounds.GetDefaultSound(type)
|
||||
};
|
||||
|
||||
@@ -418,6 +413,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
NotificationType.Error => _configService.Current.LightlessErrorNotification,
|
||||
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
|
||||
NotificationType.Download => _configService.Current.LightlessDownloadNotification,
|
||||
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
|
||||
_ => NotificationLocation.LightlessUi
|
||||
};
|
||||
|
||||
@@ -505,6 +501,18 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
case NotificationType.Error:
|
||||
PrintErrorChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.PairRequest:
|
||||
PrintPairRequestChat(msg.Title, msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Performance:
|
||||
PrintPerformanceChat(msg.Title, msg.Message);
|
||||
break;
|
||||
|
||||
// Download notifications don't support chat output, will be a giga spam otherwise
|
||||
case NotificationType.Download:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,12 +536,41 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintPairRequestChat(string? title, string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
|
||||
.AddUiForeground("Pair Request: ", 541).AddUiForegroundOff()
|
||||
.AddText(title ?? message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintPerformanceChat(string? title, string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
|
||||
.AddUiForeground("Performance: ", 508).AddUiForegroundOff()
|
||||
.AddText(title ?? message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void HandlePairRequestReceived(PairRequestReceivedMessage msg)
|
||||
{
|
||||
var request = _pairRequestService.RegisterIncomingRequest(msg.HashedCid, msg.Message);
|
||||
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName;
|
||||
|
||||
_shownPairRequestNotifications.Add(request.HashedCid);
|
||||
ShowPairRequestNotification(
|
||||
senderName,
|
||||
request.HashedCid,
|
||||
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName),
|
||||
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid, senderName));
|
||||
}
|
||||
|
||||
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
|
||||
{
|
||||
var activeRequests = _pairRequestService.GetActiveRequests();
|
||||
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet();
|
||||
|
||||
// Dismiss notifications for requests that are no longer active
|
||||
// Dismiss notifications for requests that are no longer active (expired)
|
||||
var notificationsToRemove = _shownPairRequestNotifications
|
||||
.Where(hashedCid => !activeRequestIds.Contains(hashedCid))
|
||||
.ToList();
|
||||
@@ -544,18 +581,165 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage(notificationId));
|
||||
_shownPairRequestNotifications.Remove(hashedCid);
|
||||
}
|
||||
}
|
||||
|
||||
// Show/update notifications for all active requests
|
||||
foreach (var request in activeRequests)
|
||||
private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
|
||||
{
|
||||
var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList();
|
||||
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f;
|
||||
var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
_shownPairRequestNotifications.Add(request.HashedCid);
|
||||
ShowPairRequestNotification(
|
||||
request.DisplayName,
|
||||
request.HashedCid,
|
||||
() => _pairRequestService.AcceptPairRequest(request.HashedCid, request.DisplayName),
|
||||
() => _pairRequestService.DeclinePairRequest(request.HashedCid)
|
||||
);
|
||||
Id = "pair_download_progress",
|
||||
Title = "Downloading Pair Data",
|
||||
Message = message,
|
||||
Type = NotificationType.Download,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||
ShowProgress = true,
|
||||
Progress = totalProgress
|
||||
};
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
|
||||
private void HandlePerformanceNotification(PerformanceNotificationMessage msg)
|
||||
{
|
||||
var location = GetNotificationLocation(NotificationType.Performance);
|
||||
|
||||
// Show in chat if configured
|
||||
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
|
||||
{
|
||||
ShowChat(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
|
||||
}
|
||||
|
||||
// Show Lightless notification if configured and action buttons are enabled
|
||||
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
|
||||
&& _configService.Current.UseLightlessNotifications
|
||||
&& _configService.Current.ShowPerformanceNotificationActions)
|
||||
{
|
||||
var actions = CreatePerformanceActions(msg.UserData, msg.IsPaused, msg.PlayerName);
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Title = msg.Title,
|
||||
Message = msg.Message,
|
||||
Type = NotificationType.Performance,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
|
||||
Actions = actions,
|
||||
SoundEffectId = GetSoundEffectId(NotificationType.Performance, null)
|
||||
};
|
||||
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
|
||||
{
|
||||
// Fall back to regular notification without action buttons
|
||||
HandleNotificationMessage(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private List<LightlessNotificationAction> CreatePerformanceActions(UserData userData, bool isPaused, string playerName)
|
||||
{
|
||||
var actions = new List<LightlessNotificationAction>();
|
||||
|
||||
if (isPaused)
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Unpause",
|
||||
Icon = FontAwesomeIcon.Play,
|
||||
Color = UIColors.Get("LightlessGreen"),
|
||||
IsPrimary = true,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Mediator.Publish(new CyclePauseMessage(userData));
|
||||
DismissNotification(notification);
|
||||
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Player Unpaused",
|
||||
$"Successfully unpaused {displayName}",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to unpause player {uid}", userData.UID);
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Unpause Failed",
|
||||
$"Failed to unpause {displayName}",
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Pause",
|
||||
Icon = FontAwesomeIcon.Pause,
|
||||
Color = UIColors.Get("LightlessOrange"),
|
||||
IsPrimary = true,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Mediator.Publish(new PauseMessage(userData));
|
||||
DismissNotification(notification);
|
||||
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Player Paused",
|
||||
$"Successfully paused {displayName}",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pause player {uid}", userData.UID);
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Pause Failed",
|
||||
$"Failed to pause {displayName}",
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add dismiss button
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Dismiss",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
IsPrimary = false,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
DismissNotification(notification);
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private string GetUserDisplayName(UserData userData, string playerName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
|
||||
{
|
||||
return $"{playerName} ({userData.Alias})";
|
||||
}
|
||||
return $"{playerName} ({userData.UID})";
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private int _currentLimit;
|
||||
private int _pendingReductions;
|
||||
private int _pendingIncrements;
|
||||
private int _waiting;
|
||||
private int _inFlight;
|
||||
|
||||
@@ -70,7 +71,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
|
||||
if (!IsEnabled)
|
||||
{
|
||||
_semaphore.Release();
|
||||
TryReleaseSemaphore();
|
||||
return NoopReleaser.Instance;
|
||||
}
|
||||
|
||||
@@ -90,18 +91,12 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
var releaseAmount = HardLimit - _semaphore.CurrentCount;
|
||||
if (releaseAmount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_semaphore.Release(releaseAmount);
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// ignore, already at max
|
||||
}
|
||||
TryReleaseSemaphore(releaseAmount);
|
||||
}
|
||||
|
||||
_currentLimit = desiredLimit;
|
||||
_pendingReductions = 0;
|
||||
_pendingIncrements = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,10 +108,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
if (desiredLimit > _currentLimit)
|
||||
{
|
||||
var increment = desiredLimit - _currentLimit;
|
||||
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount);
|
||||
if (allowed > 0)
|
||||
_pendingIncrements += increment;
|
||||
|
||||
var available = HardLimit - _semaphore.CurrentCount;
|
||||
var toRelease = Math.Min(_pendingIncrements, available);
|
||||
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
|
||||
{
|
||||
_semaphore.Release(allowed);
|
||||
_pendingIncrements -= toRelease;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -133,6 +131,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
{
|
||||
_pendingReductions += remaining;
|
||||
}
|
||||
|
||||
if (_pendingIncrements > 0)
|
||||
{
|
||||
var offset = Math.Min(_pendingIncrements, _pendingReductions);
|
||||
_pendingIncrements -= offset;
|
||||
_pendingReductions -= offset;
|
||||
}
|
||||
}
|
||||
|
||||
_currentLimit = desiredLimit;
|
||||
@@ -146,6 +151,25 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
return Math.Clamp(configured, 1, HardLimit);
|
||||
}
|
||||
|
||||
private bool TryReleaseSemaphore(int count = 1)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_semaphore.Release(count);
|
||||
return true;
|
||||
}
|
||||
catch (SemaphoreFullException ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseOne()
|
||||
{
|
||||
var inFlight = Interlocked.Decrement(ref _inFlight);
|
||||
@@ -166,9 +190,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
_pendingReductions--;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingIncrements > 0)
|
||||
{
|
||||
if (!TryReleaseSemaphore())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingIncrements--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_semaphore.Release();
|
||||
TryReleaseSemaphore();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -14,12 +10,17 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly Lazy<WebAPI.ApiController> _apiController;
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Lock _syncRoot = new();
|
||||
private readonly List<PairRequestEntry> _requests = [];
|
||||
|
||||
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PairRequestService(ILogger<PairRequestService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager, Lazy<WebAPI.ApiController> apiController)
|
||||
public PairRequestService(
|
||||
ILogger<PairRequestService> logger,
|
||||
LightlessMediator mediator,
|
||||
DalamudUtilService dalamudUtil,
|
||||
PairManager pairManager,
|
||||
Lazy<WebAPI.ApiController> apiController)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_dalamudUtil = dalamudUtil;
|
||||
@@ -184,7 +185,7 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0;
|
||||
return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
|
||||
}
|
||||
|
||||
public void AcceptPairRequest(string hashedCid, string displayName)
|
||||
@@ -215,9 +216,13 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
||||
});
|
||||
}
|
||||
|
||||
public void DeclinePairRequest(string hashedCid)
|
||||
public void DeclinePairRequest(string hashedCid, string displayName)
|
||||
{
|
||||
RemoveRequest(hashedCid);
|
||||
Mediator.Publish(new NotificationMessage("Pair request declined",
|
||||
"Declined " + displayName + "'s pending pair request.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,23 +78,26 @@ public class PlayerPerformanceService
|
||||
string warningText = string.Empty;
|
||||
if (exceedsTris && !exceedsVram)
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold (" +
|
||||
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
|
||||
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
|
||||
}
|
||||
else if (!exceedsTris)
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
|
||||
}
|
||||
else
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB) and " +
|
||||
$"triangle warning threshold ({triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
|
||||
}
|
||||
|
||||
_mediator.Publish(new NotificationMessage($"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
|
||||
warningText, LightlessConfiguration.Models.NotificationType.Warning));
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
|
||||
warningText,
|
||||
pairHandler.Pair.UserData,
|
||||
pairHandler.Pair.IsPaused,
|
||||
pairHandler.Pair.PlayerName));
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -138,11 +141,15 @@ public class PlayerPerformanceService
|
||||
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
|
||||
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold (" +
|
||||
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)" +
|
||||
$" and has been automatically paused.",
|
||||
LightlessConfiguration.Models.NotificationType.Warning));
|
||||
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
|
||||
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
|
||||
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
message,
|
||||
pair.UserData,
|
||||
true,
|
||||
pair.PlayerName));
|
||||
|
||||
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
|
||||
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
|
||||
@@ -214,11 +221,15 @@ public class PlayerPerformanceService
|
||||
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
|
||||
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB)" +
|
||||
$" and has been automatically paused.",
|
||||
LightlessConfiguration.Models.NotificationType.Warning));
|
||||
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
|
||||
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
message,
|
||||
pair.UserData,
|
||||
true,
|
||||
pair.PlayerName));
|
||||
|
||||
_mediator.Publish(new PauseMessage(pair.UserData));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
@@ -18,10 +19,11 @@ public class UiFactory
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
|
||||
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
|
||||
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
|
||||
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService)
|
||||
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -31,12 +33,13 @@ public class UiFactory
|
||||
_serverConfigManager = serverConfigManager;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_fileDialogManager = fileDialogManager;
|
||||
}
|
||||
|
||||
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
|
||||
{
|
||||
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
|
||||
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
|
||||
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
|
||||
}
|
||||
|
||||
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
|
||||
|
||||
@@ -23,8 +23,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
|
||||
IEnumerable<WindowMediatorSubscriberBase> windows,
|
||||
UiFactory uiFactory, FileDialogManager fileDialogManager,
|
||||
LightlessMediator lightlessMediator,
|
||||
NotificationService notificationService) : base(logger, lightlessMediator)
|
||||
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_logger.LogTrace("Creating {type}", GetType().Name);
|
||||
|
||||
@@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
|
||||
if (!_charaDataManager.BrioAvailable)
|
||||
{
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed);
|
||||
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters, you are required to have Brio installed.", ImGuiColors.DalamudRed);
|
||||
UiSharedService.DistanceSeparator();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility;
|
||||
@@ -16,12 +15,14 @@ using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Components;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using LightlessSync.WebAPI.Files.Models;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
@@ -56,7 +57,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
private readonly BroadcastService _broadcastService;
|
||||
|
||||
private List<IDrawFolder> _drawFolders;
|
||||
private Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>>? _cachedAnalysis;
|
||||
private Pair? _lastAddedUser;
|
||||
private string _lastAddedUserComment = string.Empty;
|
||||
private Vector2 _lastPosition = Vector2.One;
|
||||
@@ -382,15 +382,26 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Upload);
|
||||
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
|
||||
|
||||
if (currentUploads.Any())
|
||||
if (currentUploads.Count > 0)
|
||||
{
|
||||
var totalUploads = currentUploads.Count;
|
||||
int totalUploads = currentUploads.Count;
|
||||
int doneUploads = 0;
|
||||
long totalUploaded = 0;
|
||||
long totalToUpload = 0;
|
||||
|
||||
var doneUploads = currentUploads.Count(c => c.IsTransferred);
|
||||
var activeUploads = currentUploads.Count(c => !c.IsTransferred);
|
||||
foreach (var upload in currentUploads)
|
||||
{
|
||||
if (upload.IsTransferred)
|
||||
{
|
||||
doneUploads++;
|
||||
}
|
||||
|
||||
totalUploaded += upload.Transferred;
|
||||
totalToUpload += upload.Total;
|
||||
}
|
||||
|
||||
int activeUploads = totalUploads - doneUploads;
|
||||
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8);
|
||||
var totalUploaded = currentUploads.Sum(c => c.Transferred);
|
||||
var totalToUpload = currentUploads.Sum(c => c.Total);
|
||||
|
||||
ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})");
|
||||
var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})";
|
||||
@@ -405,17 +416,17 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
ImGui.TextUnformatted("No uploads in progress");
|
||||
}
|
||||
|
||||
var currentDownloads = BuildCurrentDownloadSnapshot();
|
||||
var downloadSummary = GetDownloadSummary();
|
||||
ImGui.AlignTextToFramePadding();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Download);
|
||||
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
|
||||
|
||||
if (currentDownloads.Any())
|
||||
if (downloadSummary.HasDownloads)
|
||||
{
|
||||
var totalDownloads = currentDownloads.Sum(c => c.TotalFiles);
|
||||
var doneDownloads = currentDownloads.Sum(c => c.TransferredFiles);
|
||||
var totalDownloaded = currentDownloads.Sum(c => c.TransferredBytes);
|
||||
var totalToDownload = currentDownloads.Sum(c => c.TotalBytes);
|
||||
var totalDownloads = downloadSummary.TotalFiles;
|
||||
var doneDownloads = downloadSummary.TransferredFiles;
|
||||
var totalDownloaded = downloadSummary.TransferredBytes;
|
||||
var totalToDownload = downloadSummary.TotalBytes;
|
||||
|
||||
ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}");
|
||||
var downloadText =
|
||||
@@ -433,27 +444,35 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
|
||||
private List<FileDownloadStatus> BuildCurrentDownloadSnapshot()
|
||||
private DownloadSummary GetDownloadSummary()
|
||||
{
|
||||
List<FileDownloadStatus> snapshot = new();
|
||||
long totalBytes = 0;
|
||||
long transferredBytes = 0;
|
||||
int totalFiles = 0;
|
||||
int transferredFiles = 0;
|
||||
|
||||
foreach (var kvp in _currentDownloads.ToArray())
|
||||
{
|
||||
var value = kvp.Value;
|
||||
if (value == null || value.Count == 0)
|
||||
if (kvp.Value is not { Count: > 0 } statuses)
|
||||
{
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
snapshot.AddRange(value.Values.ToArray());
|
||||
}
|
||||
catch (System.ArgumentException)
|
||||
|
||||
foreach (var status in statuses.Values)
|
||||
{
|
||||
// skibidi
|
||||
totalBytes += status.TotalBytes;
|
||||
transferredBytes += status.TransferredBytes;
|
||||
totalFiles += status.TotalFiles;
|
||||
transferredFiles += status.TransferredFiles;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
|
||||
}
|
||||
|
||||
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
|
||||
{
|
||||
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
|
||||
}
|
||||
|
||||
private void DrawUIDHeader()
|
||||
@@ -480,7 +499,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
//Getting information of character and triangles threshold to show overlimit status in UID bar.
|
||||
_cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone();
|
||||
var analysisSummary = _characterAnalyzer.LatestSummary;
|
||||
|
||||
Vector2 uidTextSize, iconSize;
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
@@ -509,6 +528,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f);
|
||||
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
|
||||
ImGui.Text("Lightfinder");
|
||||
@@ -556,6 +576,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
@@ -574,7 +595,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
|
||||
var cursorPos = ImGui.GetCursorScreenPos();
|
||||
var fontPtr = ImGui.GetFont();
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr);
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-header");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -591,56 +612,40 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
|
||||
UiSharedService.AttachToolTip("Click to copy");
|
||||
|
||||
if (_cachedAnalysis != null && _apiController.ServerState is ServerState.Connected)
|
||||
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
|
||||
{
|
||||
var firstEntry = _cachedAnalysis.FirstOrDefault();
|
||||
var valueDict = firstEntry.Value;
|
||||
if (valueDict != null && valueDict.Count > 0)
|
||||
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
|
||||
if (objectSummary.HasEntries)
|
||||
{
|
||||
var groupedfiles = valueDict
|
||||
.Select(v => v.Value)
|
||||
.Where(v => v != null)
|
||||
.GroupBy(f => f.FileType, StringComparer.Ordinal)
|
||||
.OrderBy(k => k.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var actualVramUsage = objectSummary.TexOriginalBytes;
|
||||
var actualTriCount = objectSummary.TotalTriangles;
|
||||
|
||||
var actualTriCount = valueDict
|
||||
.Select(v => v.Value)
|
||||
.Where(v => v != null)
|
||||
.Sum(f => f.Triangles);
|
||||
var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
|
||||
var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
|
||||
|
||||
if (groupedfiles != null)
|
||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||
{
|
||||
//Checking of VRAM threshhold
|
||||
var texGroup = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal));
|
||||
var actualVramUsage = texGroup != null ? texGroup.Sum(f => f.OriginalSize) : 0L;
|
||||
var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
|
||||
var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(cursorY + 15f);
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||
string warningMessage = "";
|
||||
if (isOverTriHold)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(cursorY + 15f);
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
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 (ImGui.IsItemClicked())
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,7 +668,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor);
|
||||
var cursorPos = ImGui.GetCursorScreenPos();
|
||||
var fontPtr = ImGui.GetFont();
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr);
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-footer");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -704,23 +709,23 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
//Filter of not foldered syncshells
|
||||
var groupFolders = new List<IDrawFolder>();
|
||||
var groupFolders = new List<GroupFolder>();
|
||||
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
|
||||
if (FilterNotTaggedSyncshells(group))
|
||||
{
|
||||
groupFolders.Add(_drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs));
|
||||
groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)));
|
||||
}
|
||||
}
|
||||
|
||||
//Filter of grouped up syncshells (All Syncshells Folder)
|
||||
if (_configService.Current.GroupUpSyncshells)
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService,
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService,
|
||||
_selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
|
||||
else
|
||||
drawFolders.AddRange(groupFolders);
|
||||
drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder));
|
||||
|
||||
//Filter of grouped/foldered pairs
|
||||
foreach (var tag in _tagHandler.GetAllPairTagsSorted())
|
||||
@@ -734,7 +739,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
//Filter of grouped/foldered syncshells
|
||||
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted())
|
||||
{
|
||||
var syncshellFolderTags = new List<IDrawFolder>();
|
||||
var syncshellFolderTags = new List<GroupFolder>();
|
||||
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
|
||||
@@ -743,11 +748,11 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
out ImmutableList<Pair> allGroupPairs,
|
||||
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
|
||||
syncshellFolderTags.Add(_drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs));
|
||||
syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)));
|
||||
}
|
||||
}
|
||||
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
|
||||
}
|
||||
|
||||
//Filter of not grouped/foldered and offline pairs
|
||||
@@ -921,4 +926,4 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
_wasOpen = IsOpen;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.WebAPI;
|
||||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
|
||||
@@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components;
|
||||
public class DrawGroupedGroupFolder : IDrawFolder
|
||||
{
|
||||
private readonly string _tag;
|
||||
private readonly IEnumerable<IDrawFolder> _groups;
|
||||
private readonly IEnumerable<GroupFolder> _groups;
|
||||
private readonly TagHandler _tagHandler;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
|
||||
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
|
||||
private bool _wasHovered = false;
|
||||
private float _menuWidth;
|
||||
|
||||
public IImmutableList<DrawUserPair> DrawPairs => throw new NotSupportedException();
|
||||
public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
|
||||
public int TotalPairs => _groups.Sum(g => g.TotalPairs);
|
||||
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
|
||||
public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
|
||||
public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
|
||||
|
||||
public DrawGroupedGroupFolder(IEnumerable<IDrawFolder> groups, TagHandler tagHandler, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
|
||||
public DrawGroupedGroupFolder(IEnumerable<GroupFolder> groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
|
||||
{
|
||||
_groups = groups;
|
||||
_tagHandler = tagHandler;
|
||||
@@ -30,6 +35,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
_selectSyncshellForTagUi = selectSyncshellForTagUi;
|
||||
_renameSyncshellTagUi = renameSyncshellTagUi;
|
||||
_tag = tag;
|
||||
_apiController = apiController;
|
||||
}
|
||||
|
||||
public void Draw()
|
||||
@@ -42,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
|
||||
using var id = ImRaii.PushId(_id);
|
||||
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
|
||||
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
|
||||
using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
|
||||
@@ -83,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
{
|
||||
ImGui.TextUnformatted(_tag);
|
||||
|
||||
ImGui.SameLine();
|
||||
DrawPauseButton();
|
||||
ImGui.SameLine();
|
||||
DrawMenu();
|
||||
} else
|
||||
{
|
||||
ImGui.TextUnformatted("All Syncshells");
|
||||
|
||||
ImGui.SameLine();
|
||||
DrawPauseButton();
|
||||
}
|
||||
}
|
||||
color.Dispose();
|
||||
@@ -100,11 +111,49 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
using var indent = ImRaii.PushIndent(20f);
|
||||
foreach (var entry in _groups)
|
||||
{
|
||||
entry.Draw();
|
||||
entry.GroupDrawFolder.Draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void DrawPauseButton()
|
||||
{
|
||||
if (DrawPairs.Count > 0)
|
||||
{
|
||||
var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
|
||||
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
|
||||
|
||||
var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon);
|
||||
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
|
||||
if (_tag != "")
|
||||
{
|
||||
var spacingX = ImGui.GetStyle().ItemSpacing.X;
|
||||
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
|
||||
ImGui.SameLine(windowEndX - pauseButtonSize.X - menuButtonSize.X - spacingX);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.SameLine(windowEndX - pauseButtonSize.X);
|
||||
}
|
||||
|
||||
|
||||
if (_uiSharedService.IconButton(pauseIcon))
|
||||
{
|
||||
ChangePauseStateGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void ChangePauseStateGroups()
|
||||
{
|
||||
foreach(var group in _groups)
|
||||
{
|
||||
var perm = group.GroupFullInfo.GroupUserPermissions;
|
||||
perm.SetPaused(!perm.IsPaused());
|
||||
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(group.GroupFullInfo.Group, new(_apiController.UID), perm));
|
||||
}
|
||||
}
|
||||
|
||||
protected void DrawMenu()
|
||||
{
|
||||
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
@@ -13,6 +14,9 @@ using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace LightlessSync.UI.Components;
|
||||
|
||||
@@ -32,6 +36,8 @@ public class DrawUserPair
|
||||
private readonly CharaDataManager _charaDataManager;
|
||||
private float _menuWidth = -1;
|
||||
private bool _wasHovered = false;
|
||||
private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty;
|
||||
private string _cachedTooltip = string.Empty;
|
||||
|
||||
public DrawUserPair(string id, Pair entry, List<GroupFullInfoDto> syncedGroups,
|
||||
GroupFullInfoDto? currentGroup,
|
||||
@@ -190,15 +196,12 @@ public class DrawUserPair
|
||||
|
||||
private void DrawLeftSide()
|
||||
{
|
||||
string userPairText = string.Empty;
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
|
||||
if (_pair.IsPaused)
|
||||
{
|
||||
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
|
||||
_uiSharedService.IconText(FontAwesomeIcon.PauseCircle);
|
||||
userPairText = _pair.UserData.AliasOrUID + " is paused";
|
||||
}
|
||||
else if (!_pair.IsOnline)
|
||||
{
|
||||
@@ -207,12 +210,10 @@ public class DrawUserPair
|
||||
? FontAwesomeIcon.ArrowsLeftRight
|
||||
: (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
|
||||
? FontAwesomeIcon.User : FontAwesomeIcon.Users));
|
||||
userPairText = _pair.UserData.AliasOrUID + " is offline";
|
||||
}
|
||||
else if (_pair.IsVisible)
|
||||
{
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue"));
|
||||
userPairText = _pair.UserData.AliasOrUID + " is visible: " + _pair.PlayerName + Environment.NewLine + "Click to target this player";
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
_mediator.Publish(new TargetPairMessage(_pair));
|
||||
@@ -223,46 +224,9 @@ public class DrawUserPair
|
||||
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
|
||||
_uiSharedService.IconText(_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
|
||||
? FontAwesomeIcon.User : FontAwesomeIcon.Users);
|
||||
userPairText = _pair.UserData.AliasOrUID + " is online";
|
||||
}
|
||||
|
||||
if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + "User has not added you back";
|
||||
}
|
||||
else if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + "You are directly Paired";
|
||||
}
|
||||
|
||||
if (_pair.LastAppliedDataBytes >= 0)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator;
|
||||
userPairText += ((!_pair.IsPaired) ? "(Last) " : string.Empty) + "Mods Info" + Environment.NewLine;
|
||||
userPairText += "Files Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataBytes, true);
|
||||
if (_pair.LastAppliedApproximateVRAMBytes >= 0)
|
||||
{
|
||||
userPairText += Environment.NewLine + "Approx. VRAM Usage: " + UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes, true);
|
||||
}
|
||||
if (_pair.LastAppliedDataTris >= 0)
|
||||
{
|
||||
userPairText += Environment.NewLine + "Approx. Triangle Count (excl. Vanilla): "
|
||||
+ (_pair.LastAppliedDataTris > 1000 ? (_pair.LastAppliedDataTris / 1000d).ToString("0.0'k'") : _pair.LastAppliedDataTris);
|
||||
}
|
||||
}
|
||||
|
||||
if (_syncedGroups.Any())
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + string.Join(Environment.NewLine,
|
||||
_syncedGroups.Select(g =>
|
||||
{
|
||||
var groupNote = _serverConfigurationManager.GetNoteForGid(g.GID);
|
||||
var groupString = string.IsNullOrEmpty(groupNote) ? g.GroupAliasOrGID : $"{groupNote} ({g.GroupAliasOrGID})";
|
||||
return "Paired through " + groupString;
|
||||
}));
|
||||
}
|
||||
|
||||
UiSharedService.AttachToolTip(userPairText);
|
||||
UiSharedService.AttachToolTip(GetUserTooltip());
|
||||
|
||||
if (_performanceConfigService.Current.ShowPerformanceIndicator
|
||||
&& !_performanceConfigService.Current.UIDsToIgnore
|
||||
@@ -327,6 +291,143 @@ public class DrawUserPair
|
||||
_displayHandler.DrawPairText(_id, _pair, leftSide, () => rightSide - leftSide);
|
||||
}
|
||||
|
||||
private string GetUserTooltip()
|
||||
{
|
||||
List<string>? groupDisplays = null;
|
||||
if (_syncedGroups.Count > 0)
|
||||
{
|
||||
groupDisplays = new List<string>(_syncedGroups.Count);
|
||||
foreach (var group in _syncedGroups)
|
||||
{
|
||||
var groupNote = _serverConfigurationManager.GetNoteForGid(group.GID);
|
||||
groupDisplays.Add(string.IsNullOrEmpty(groupNote) ? group.GroupAliasOrGID : $"{groupNote} ({group.GroupAliasOrGID})");
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new TooltipSnapshot(
|
||||
_pair.IsPaused,
|
||||
_pair.IsOnline,
|
||||
_pair.IsVisible,
|
||||
_pair.IndividualPairStatus,
|
||||
_pair.UserData.AliasOrUID,
|
||||
_pair.PlayerName ?? string.Empty,
|
||||
_pair.LastAppliedDataBytes,
|
||||
_pair.LastAppliedApproximateVRAMBytes,
|
||||
_pair.LastAppliedDataTris,
|
||||
_pair.IsPaired,
|
||||
groupDisplays is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(groupDisplays));
|
||||
|
||||
if (!_tooltipSnapshot.Equals(snapshot))
|
||||
{
|
||||
_cachedTooltip = BuildTooltip(snapshot);
|
||||
_tooltipSnapshot = snapshot;
|
||||
}
|
||||
|
||||
return _cachedTooltip;
|
||||
}
|
||||
|
||||
private static string BuildTooltip(in TooltipSnapshot snapshot)
|
||||
{
|
||||
var builder = new StringBuilder(256);
|
||||
|
||||
if (snapshot.IsPaused)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is paused");
|
||||
}
|
||||
else if (!snapshot.IsOnline)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is offline");
|
||||
}
|
||||
else if (snapshot.IsVisible)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is visible: ");
|
||||
builder.Append(snapshot.PlayerName);
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Click to target this player");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is online");
|
||||
}
|
||||
|
||||
if (snapshot.PairStatus == IndividualPairStatus.OneSided)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
builder.Append("User has not added you back");
|
||||
}
|
||||
else if (snapshot.PairStatus == IndividualPairStatus.Bidirectional)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
builder.Append("You are directly Paired");
|
||||
}
|
||||
|
||||
if (snapshot.LastAppliedDataBytes >= 0)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
if (!snapshot.IsPaired)
|
||||
{
|
||||
builder.Append("(Last) ");
|
||||
}
|
||||
builder.Append("Mods Info");
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Files Size: ");
|
||||
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedDataBytes, true));
|
||||
|
||||
if (snapshot.LastAppliedApproximateVRAMBytes >= 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Approx. VRAM Usage: ");
|
||||
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true));
|
||||
}
|
||||
|
||||
if (snapshot.LastAppliedDataTris >= 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Approx. Triangle Count (excl. Vanilla): ");
|
||||
builder.Append(snapshot.LastAppliedDataTris > 1000
|
||||
? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'")
|
||||
: snapshot.LastAppliedDataTris);
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot.GroupDisplays.IsEmpty)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
for (int i = 0; i < snapshot.GroupDisplays.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
}
|
||||
builder.Append("Paired through ");
|
||||
builder.Append(snapshot.GroupDisplays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private readonly record struct TooltipSnapshot(
|
||||
bool IsPaused,
|
||||
bool IsOnline,
|
||||
bool IsVisible,
|
||||
IndividualPairStatus PairStatus,
|
||||
string AliasOrUid,
|
||||
string PlayerName,
|
||||
long LastAppliedDataBytes,
|
||||
long LastAppliedApproximateVRAMBytes,
|
||||
long LastAppliedDataTris,
|
||||
bool IsPaired,
|
||||
ImmutableArray<string> GroupDisplays)
|
||||
{
|
||||
public static TooltipSnapshot Empty { get; } =
|
||||
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, false, ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
private void DrawPairedClientMenu()
|
||||
{
|
||||
DrawIndividualMenu();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace LightlessSync.UI.Components;
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
private readonly UiSharedService _uiShared;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
||||
private readonly NotificationService _notificationService;
|
||||
private bool _notificationDismissed = true;
|
||||
private int _lastDownloadStateHash = 0;
|
||||
|
||||
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
|
||||
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared,
|
||||
PerformanceCollectorService performanceCollectorService, NotificationService notificationService)
|
||||
PerformanceCollectorService performanceCollectorService)
|
||||
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
|
||||
{
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
@@ -35,7 +35,6 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_fileTransferManager = fileTransferManager;
|
||||
_uiShared = uiShared;
|
||||
_notificationService = notificationService;
|
||||
|
||||
SizeConstraints = new WindowSizeConstraints()
|
||||
{
|
||||
@@ -59,13 +58,21 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
|
||||
IsOpen = true;
|
||||
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
|
||||
{
|
||||
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
|
||||
_notificationDismissed = false;
|
||||
});
|
||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
||||
{
|
||||
_currentDownloads.TryRemove(msg.DownloadId, out _);
|
||||
if (!_currentDownloads.Any())
|
||||
|
||||
// Dismiss notification if all downloads are complete
|
||||
if (!_currentDownloads.Any() && !_notificationDismissed)
|
||||
{
|
||||
_notificationService.DismissPairDownloadNotification();
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
_notificationDismissed = true;
|
||||
_lastDownloadStateHash = 0;
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
|
||||
@@ -116,7 +123,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown from UI
|
||||
_logger.LogDebug("Error drawing upload progress");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -131,17 +138,19 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
// Use notification system
|
||||
if (_currentDownloads.Any())
|
||||
{
|
||||
UpdateDownloadNotification(limiterSnapshot);
|
||||
UpdateDownloadNotificationIfChanged(limiterSnapshot);
|
||||
_notificationDismissed = false;
|
||||
}
|
||||
else if (!_notificationDismissed)
|
||||
{
|
||||
_notificationService.DismissPairDownloadNotification();
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
_notificationDismissed = true;
|
||||
_lastDownloadStateHash = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use text overlay
|
||||
if (limiterSnapshot.IsEnabled)
|
||||
{
|
||||
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
|
||||
@@ -183,7 +192,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown from UI
|
||||
_logger.LogDebug("Error drawing download progress");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +264,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown on UI
|
||||
_logger.LogDebug("Error drawing upload progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,20 +307,34 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateDownloadNotification(PairProcessingLimiterSnapshot limiterSnapshot)
|
||||
private void UpdateDownloadNotificationIfChanged(PairProcessingLimiterSnapshot limiterSnapshot)
|
||||
{
|
||||
var downloadStatus = new List<(string playerName, float progress, string status)>();
|
||||
var downloadStatus = new List<(string playerName, float progress, string status)>(_currentDownloads.Count);
|
||||
var hashCode = new HashCode();
|
||||
|
||||
foreach (var item in _currentDownloads.ToList())
|
||||
foreach (var item in _currentDownloads)
|
||||
{
|
||||
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
||||
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
||||
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
||||
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
||||
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
||||
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
||||
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
||||
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
||||
var dlSlot = 0;
|
||||
var dlQueue = 0;
|
||||
var dlProg = 0;
|
||||
var dlDecomp = 0;
|
||||
long totalBytes = 0;
|
||||
long transferredBytes = 0;
|
||||
|
||||
// Single pass through the dictionary to count everything - avoid multiple LINQ iterations
|
||||
foreach (var entry in item.Value)
|
||||
{
|
||||
var fileStatus = entry.Value;
|
||||
switch (fileStatus.DownloadStatus)
|
||||
{
|
||||
case DownloadStatus.WaitingForSlot: dlSlot++; break;
|
||||
case DownloadStatus.WaitingForQueue: dlQueue++; break;
|
||||
case DownloadStatus.Downloading: dlProg++; break;
|
||||
case DownloadStatus.Decompressing: dlDecomp++; break;
|
||||
}
|
||||
totalBytes += fileStatus.TotalBytes;
|
||||
transferredBytes += fileStatus.TransferredBytes;
|
||||
}
|
||||
|
||||
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
|
||||
|
||||
@@ -323,14 +346,27 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
else status = "completed";
|
||||
|
||||
downloadStatus.Add((item.Key.Name, progress, status));
|
||||
|
||||
// Build hash from meaningful state
|
||||
hashCode.Add(item.Key.Name);
|
||||
hashCode.Add(transferredBytes);
|
||||
hashCode.Add(totalBytes);
|
||||
hashCode.Add(status);
|
||||
}
|
||||
|
||||
// Pass queue waiting count separately, show notification if there are downloads or queue items
|
||||
var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0;
|
||||
if (downloadStatus.Any() || queueWaiting > 0)
|
||||
hashCode.Add(queueWaiting);
|
||||
|
||||
var currentHash = hashCode.ToHashCode();
|
||||
|
||||
// Only update notification if state has actually changed
|
||||
if (currentHash != _lastDownloadStateHash)
|
||||
{
|
||||
_notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting);
|
||||
_lastDownloadStateHash = currentHash;
|
||||
if (downloadStatus.Count > 0 || queueWaiting > 0)
|
||||
{
|
||||
Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,19 +2,22 @@ using Dalamud.Game.Gui.Dtr;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using static LightlessSync.Services.PairRequestService;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
@@ -106,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
_logger.LogInformation("Lightfinder operation was canceled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -363,29 +366,46 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private int GetNearbyBroadcastCount()
|
||||
{
|
||||
var localHashedCid = GetLocalHashedCid();
|
||||
return _broadcastScannerService.CountActiveBroadcasts(
|
||||
string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid);
|
||||
}
|
||||
|
||||
private int GetPendingPairRequestCount()
|
||||
private List<string> GetNearbyBroadcasts()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _pairRequestService.GetActiveRequests().Count;
|
||||
var localHashedCid = GetLocalHashedCid();
|
||||
return [.. _broadcastScannerService
|
||||
.GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid)
|
||||
.Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (now >= _pairRequestNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry.");
|
||||
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PairRequestDisplay> GetPendingPairRequest()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _pairRequestService.GetActiveRequests();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (now >= _pairRequestNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry.");
|
||||
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,23 +420,15 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
|
||||
if (_broadcastService.IsBroadcasting)
|
||||
{
|
||||
var tooltipBuilder = new StringBuilder("Lightfinder - Enabled");
|
||||
|
||||
switch (config.LightfinderDtrDisplayMode)
|
||||
{
|
||||
case LightfinderDtrDisplayMode.PendingPairRequests:
|
||||
{
|
||||
var requestCount = GetPendingPairRequestCount();
|
||||
tooltipBuilder.AppendLine();
|
||||
tooltipBuilder.Append("Pending pair requests: ").Append(requestCount);
|
||||
return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
|
||||
return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
|
||||
}
|
||||
default:
|
||||
{
|
||||
var broadcastCount = GetNearbyBroadcastCount();
|
||||
tooltipBuilder.AppendLine();
|
||||
tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount);
|
||||
return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
|
||||
return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +445,18 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
return ($"{icon} OFF", colors, tooltip.ToString());
|
||||
}
|
||||
|
||||
private (string, Colors, string) FormatTooltip(string title, IEnumerable<string> names, string icon, Colors color)
|
||||
{
|
||||
var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList();
|
||||
var tooltip = new StringBuilder()
|
||||
.Append($"Lightfinder - Enabled{Environment.NewLine}")
|
||||
.Append($"{title}: {list.Count}{Environment.NewLine}")
|
||||
.AppendJoin(Environment.NewLine, list)
|
||||
.ToString();
|
||||
|
||||
return ($"{icon} {list.Count}", color, tooltip);
|
||||
}
|
||||
|
||||
private static string BuildLightfinderTooltip(string baseTooltip)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
@@ -63,7 +63,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; });
|
||||
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen);
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) =>
|
||||
Mediator.Subscribe<ClearProfileUserDataMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -91,6 +91,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
|
||||
_uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow"));
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
@@ -108,7 +109,8 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
|
||||
ImGui.Dummy(new Vector2(3));
|
||||
|
||||
var profile = _lightlessProfileManager.GetLightlessProfile(new UserData(_apiController.UID));
|
||||
var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID));
|
||||
_logger.LogInformation("Profile fetched for drawing: {profile}", profile);
|
||||
|
||||
if (ImGui.BeginTabBar("##EditProfileTabs"))
|
||||
{
|
||||
@@ -204,7 +206,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
_showFileDialogError = false;
|
||||
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null))
|
||||
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), BannerPictureBase64: null, Description: null, Tags: null))
|
||||
.ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
@@ -213,7 +215,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null));
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null, BannerPictureBase64: null, Tags: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
|
||||
if (_showFileDialogError)
|
||||
@@ -223,7 +225,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
var isNsfw = profile.IsNSFW;
|
||||
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null));
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null, BannerPictureBase64: null, Tags: null));
|
||||
}
|
||||
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
|
||||
var widthTextBox = 400;
|
||||
@@ -262,13 +264,13 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText));
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, _descriptionText, Tags: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Sets your profile description text");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, ""));
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, "", Tags: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clears your profile description text");
|
||||
|
||||
@@ -279,7 +281,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
_uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple"));
|
||||
ImGui.Dummy(new Vector2(4));
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings.");
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings. If you have the vanity role, you must interact with the Discord bot first.");
|
||||
|
||||
var hasVanity = _apiController.HasVanity;
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ public class IdDisplayHandler
|
||||
Vector2 textSize;
|
||||
using (ImRaii.PushFont(font, textIsUid))
|
||||
{
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font);
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID);
|
||||
itemMin = ImGui.GetItemRectMin();
|
||||
itemMax = ImGui.GetItemRectMax();
|
||||
//textSize = itemMax - itemMin;
|
||||
|
||||
@@ -15,19 +15,25 @@ using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
private const float NotificationMinHeight = 60f;
|
||||
private const float NotificationMaxHeight = 250f;
|
||||
private const float WindowPaddingOffset = 6f;
|
||||
private const float SlideAnimationDistance = 100f;
|
||||
private const float OutAnimationSpeedMultiplier = 0.7f;
|
||||
private const float _notificationMinHeight = 60f;
|
||||
private const float _notificationMaxHeight = 250f;
|
||||
private const float _windowPaddingOffset = 6f;
|
||||
private const float _slideAnimationDistance = 100f;
|
||||
private const float _outAnimationSpeedMultiplier = 0.7f;
|
||||
private const float _contentPaddingX = 10f;
|
||||
private const float _contentPaddingY = 6f;
|
||||
private const float _titleMessageSpacing = 4f;
|
||||
private const float _actionButtonSpacing = 8f;
|
||||
|
||||
private readonly List<LightlessNotification> _notifications = new();
|
||||
private readonly object _notificationLock = new();
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly Dictionary<string, float> _notificationYOffsets = new();
|
||||
private readonly Dictionary<string, float> _notificationTargetYOffsets = new();
|
||||
|
||||
public LightlessNotificationUI(ILogger<LightlessNotificationUI> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
||||
{
|
||||
_configService = configService;
|
||||
@@ -39,6 +45,9 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
ImGuiWindowFlags.NoNav |
|
||||
ImGuiWindowFlags.NoBackground |
|
||||
ImGuiWindowFlags.NoCollapse |
|
||||
ImGuiWindowFlags.NoInputs |
|
||||
ImGuiWindowFlags.NoTitleBar |
|
||||
ImGuiWindowFlags.NoScrollbar |
|
||||
ImGuiWindowFlags.AlwaysAutoResize;
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
@@ -49,12 +58,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
|
||||
Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage);
|
||||
Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage);
|
||||
Mediator.Subscribe<ClearAllNotificationsMessage>(this, HandleClearAllNotifications);
|
||||
}
|
||||
private void HandleNotificationMessage(LightlessNotificationMessage message) =>
|
||||
AddNotification(message.Notification);
|
||||
|
||||
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) =>
|
||||
RemoveNotification(message.NotificationId);
|
||||
private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification);
|
||||
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId);
|
||||
private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications();
|
||||
|
||||
public void AddNotification(LightlessNotification notification)
|
||||
{
|
||||
@@ -81,6 +89,13 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
existing.Progress = updated.Progress;
|
||||
existing.ShowProgress = updated.ShowProgress;
|
||||
existing.Title = updated.Title;
|
||||
|
||||
// Reset the duration timer on every update for download notifications
|
||||
if (updated.Type == NotificationType.Download)
|
||||
{
|
||||
existing.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Updated existing notification: {Title}", updated.Title);
|
||||
}
|
||||
|
||||
@@ -96,20 +111,34 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearAllNotifications()
|
||||
{
|
||||
lock (_notificationLock)
|
||||
{
|
||||
foreach (var notification in _notifications)
|
||||
{
|
||||
StartOutAnimation(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartOutAnimation(LightlessNotification notification)
|
||||
{
|
||||
notification.IsAnimatingOut = true;
|
||||
notification.IsAnimatingIn = false;
|
||||
}
|
||||
|
||||
private bool ShouldRemoveNotification(LightlessNotification notification) =>
|
||||
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||
|
||||
|
||||
lock (_notificationLock)
|
||||
{
|
||||
UpdateNotifications();
|
||||
|
||||
|
||||
if (_notifications.Count == 0)
|
||||
{
|
||||
ImGui.PopStyleVar();
|
||||
@@ -118,33 +147,45 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
|
||||
// Window auto-resizes based on content (AlwaysAutoResize flag)
|
||||
Position = CalculateWindowPosition(viewport);
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
DrawAllNotifications();
|
||||
}
|
||||
|
||||
|
||||
ImGui.PopStyleVar();
|
||||
}
|
||||
|
||||
private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport)
|
||||
{
|
||||
var x = viewport.WorkPos.X + viewport.WorkSize.X -
|
||||
_configService.Current.NotificationWidth -
|
||||
_configService.Current.NotificationOffsetX -
|
||||
WindowPaddingOffset;
|
||||
var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY;
|
||||
return new Vector2(x, y);
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
var offsetX = _configService.Current.NotificationOffsetX;
|
||||
var width = _configService.Current.NotificationWidth;
|
||||
|
||||
float posX = corner == NotificationCorner.Left
|
||||
? viewport.WorkPos.X + offsetX - _windowPaddingOffset
|
||||
: viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset;
|
||||
|
||||
return new Vector2(posX, viewport.WorkPos.Y);
|
||||
}
|
||||
|
||||
private void DrawAllNotifications()
|
||||
{
|
||||
var offsetY = _configService.Current.NotificationOffsetY;
|
||||
var startY = ImGui.GetCursorPosY() + offsetY;
|
||||
|
||||
for (int i = 0; i < _notifications.Count; i++)
|
||||
{
|
||||
DrawNotification(_notifications[i], i);
|
||||
var notification = _notifications[i];
|
||||
|
||||
if (i < _notifications.Count - 1)
|
||||
if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing));
|
||||
ImGui.SetCursorPosY(startY + yOffset);
|
||||
}
|
||||
|
||||
DrawNotification(notification, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,18 +215,65 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
|
||||
private void UpdateAnimationsAndRemoveExpired(float deltaTime)
|
||||
{
|
||||
UpdateTargetYPositions();
|
||||
|
||||
for (int i = _notifications.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var notification = _notifications[i];
|
||||
UpdateNotificationAnimation(notification, deltaTime);
|
||||
UpdateNotificationYOffset(notification, deltaTime);
|
||||
|
||||
if (ShouldRemoveNotification(notification))
|
||||
{
|
||||
_notifications.RemoveAt(i);
|
||||
_notificationYOffsets.Remove(notification.Id);
|
||||
_notificationTargetYOffsets.Remove(notification.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTargetYPositions()
|
||||
{
|
||||
float currentY = 0f;
|
||||
|
||||
for (int i = 0; i < _notifications.Count; i++)
|
||||
{
|
||||
var notification = _notifications[i];
|
||||
|
||||
if (!_notificationTargetYOffsets.ContainsKey(notification.Id))
|
||||
{
|
||||
_notificationTargetYOffsets[notification.Id] = currentY;
|
||||
_notificationYOffsets[notification.Id] = currentY;
|
||||
}
|
||||
else
|
||||
{
|
||||
_notificationTargetYOffsets[notification.Id] = currentY;
|
||||
}
|
||||
|
||||
currentY += CalculateNotificationHeight(notification) + _configService.Current.NotificationSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNotificationYOffset(LightlessNotification notification, float deltaTime)
|
||||
{
|
||||
if (!_notificationYOffsets.ContainsKey(notification.Id) || !_notificationTargetYOffsets.ContainsKey(notification.Id))
|
||||
return;
|
||||
|
||||
var current = _notificationYOffsets[notification.Id];
|
||||
var target = _notificationTargetYOffsets[notification.Id];
|
||||
var diff = target - current;
|
||||
|
||||
if (Math.Abs(diff) < 0.5f)
|
||||
{
|
||||
_notificationYOffsets[notification.Id] = target;
|
||||
}
|
||||
else
|
||||
{
|
||||
var speed = _configService.Current.NotificationSlideSpeed;
|
||||
_notificationYOffsets[notification.Id] = current + (diff * deltaTime * speed);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime)
|
||||
{
|
||||
if (notification.IsAnimatingIn && notification.AnimationProgress < 1f)
|
||||
@@ -196,7 +284,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f)
|
||||
{
|
||||
notification.AnimationProgress = Math.Max(0f,
|
||||
notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * OutAnimationSpeedMultiplier);
|
||||
notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * _outAnimationSpeedMultiplier);
|
||||
}
|
||||
else if (!notification.IsAnimatingOut && !notification.IsDismissed)
|
||||
{
|
||||
@@ -209,20 +297,24 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRemoveNotification(LightlessNotification notification) =>
|
||||
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
|
||||
private Vector2 CalculateSlideOffset(float alpha)
|
||||
{
|
||||
var distance = (1f - alpha) * _slideAnimationDistance;
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0);
|
||||
}
|
||||
|
||||
private void DrawNotification(LightlessNotification notification, int index)
|
||||
{
|
||||
var alpha = notification.AnimationProgress;
|
||||
if (alpha <= 0f) return;
|
||||
|
||||
var slideOffset = (1f - alpha) * SlideAnimationDistance;
|
||||
var slideOffset = CalculateSlideOffset(alpha);
|
||||
var originalCursorPos = ImGui.GetCursorPos();
|
||||
ImGui.SetCursorPosX(originalCursorPos.X + slideOffset);
|
||||
ImGui.SetCursorPos(originalCursorPos + slideOffset);
|
||||
|
||||
var notificationHeight = CalculateNotificationHeight(notification);
|
||||
var notificationWidth = _configService.Current.NotificationWidth - slideOffset;
|
||||
var notificationWidth = _configService.Current.NotificationWidth;
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||
|
||||
@@ -253,6 +345,13 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
DrawBackground(drawList, windowPos, windowSize, bgColor);
|
||||
DrawAccentBar(drawList, windowPos, windowSize, accentColor);
|
||||
DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList);
|
||||
|
||||
// Draw download progress bar above duration bar for download notifications
|
||||
if (notification.Type == NotificationType.Download && notification.ShowProgress)
|
||||
{
|
||||
DrawDownloadProgressBar(notification, alpha, windowPos, windowSize, drawList);
|
||||
}
|
||||
|
||||
DrawNotificationText(notification, alpha);
|
||||
}
|
||||
|
||||
@@ -308,20 +407,33 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor)
|
||||
{
|
||||
var accentWidth = _configService.Current.NotificationAccentBarWidth;
|
||||
if (accentWidth > 0f)
|
||||
if (accentWidth <= 0f) return;
|
||||
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
Vector2 accentStart, accentEnd;
|
||||
|
||||
if (corner == NotificationCorner.Left)
|
||||
{
|
||||
drawList.AddRectFilled(
|
||||
windowPos,
|
||||
windowPos + new Vector2(accentWidth, windowSize.Y),
|
||||
ImGui.ColorConvertFloat4ToU32(accentColor),
|
||||
3f
|
||||
);
|
||||
accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0);
|
||||
accentEnd = windowPos + windowSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
accentStart = windowPos;
|
||||
accentEnd = windowPos + new Vector2(accentWidth, windowSize.Y);
|
||||
}
|
||||
|
||||
drawList.AddRectFilled(
|
||||
accentStart,
|
||||
accentEnd,
|
||||
ImGui.ColorConvertFloat4ToU32(accentColor),
|
||||
3f
|
||||
);
|
||||
}
|
||||
|
||||
private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
|
||||
{
|
||||
var progress = CalculateProgress(notification);
|
||||
var progress = CalculateDurationProgress(notification);
|
||||
var progressBarColor = UIColors.Get("LightlessBlue");
|
||||
var progressHeight = 2f;
|
||||
var progressY = windowPos.Y + windowSize.Y - progressHeight;
|
||||
@@ -335,13 +447,26 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateProgress(LightlessNotification notification)
|
||||
private void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
|
||||
{
|
||||
if (notification.Type == NotificationType.Download && notification.ShowProgress)
|
||||
{
|
||||
return Math.Clamp(notification.Progress, 0f, 1f);
|
||||
}
|
||||
var progress = Math.Clamp(notification.Progress, 0f, 1f);
|
||||
var progressBarColor = UIColors.Get("LightlessGreen");
|
||||
var progressHeight = 3f;
|
||||
// Position above the duration bar (2px duration bar + 1px spacing)
|
||||
var progressY = windowPos.Y + windowSize.Y - progressHeight - 3f;
|
||||
var progressWidth = windowSize.X * progress;
|
||||
|
||||
DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha);
|
||||
|
||||
if (progress > 0)
|
||||
{
|
||||
DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateDurationProgress(LightlessNotification notification)
|
||||
{
|
||||
// Calculate duration timer progress
|
||||
var elapsed = DateTime.UtcNow - notification.CreatedAt;
|
||||
return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds));
|
||||
}
|
||||
@@ -371,81 +496,112 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
|
||||
private void DrawNotificationText(LightlessNotification notification, float alpha)
|
||||
{
|
||||
var padding = new Vector2(10f, 6f);
|
||||
var contentPos = new Vector2(padding.X, padding.Y);
|
||||
var contentPos = new Vector2(_contentPaddingX, _contentPaddingY);
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2);
|
||||
var contentWidth = CalculateContentWidth(windowSize.X);
|
||||
|
||||
ImGui.SetCursorPos(contentPos);
|
||||
|
||||
var titleHeight = DrawTitle(notification, contentSize.X, alpha);
|
||||
DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha);
|
||||
var titleHeight = DrawTitle(notification, contentWidth, alpha);
|
||||
DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha);
|
||||
|
||||
if (notification.Actions.Count > 0)
|
||||
if (HasActions(notification))
|
||||
{
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y);
|
||||
ImGui.SetCursorPosX(contentPos.X);
|
||||
DrawNotificationActions(notification, contentSize.X, alpha);
|
||||
PositionActionsAtBottom(windowSize.Y);
|
||||
DrawNotificationActions(notification, contentWidth, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateContentWidth(float windowWidth) =>
|
||||
windowWidth - (_contentPaddingX * 2);
|
||||
|
||||
private bool HasActions(LightlessNotification notification) =>
|
||||
notification.Actions.Count > 0;
|
||||
|
||||
private void PositionActionsAtBottom(float windowHeight)
|
||||
{
|
||||
var actionHeight = ImGui.GetFrameHeight();
|
||||
var bottomY = windowHeight - _contentPaddingY - actionHeight;
|
||||
ImGui.SetCursorPosY(bottomY);
|
||||
ImGui.SetCursorPosX(_contentPaddingX);
|
||||
}
|
||||
|
||||
private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha)
|
||||
{
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha)))
|
||||
var titleColor = new Vector4(1f, 1f, 1f, alpha);
|
||||
var titleText = FormatTitleText(notification);
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
|
||||
{
|
||||
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
|
||||
var titleStartY = ImGui.GetCursorPosY();
|
||||
|
||||
var titleText = _configService.Current.ShowNotificationTimestamp
|
||||
? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}"
|
||||
: notification.Title;
|
||||
|
||||
ImGui.TextWrapped(titleText);
|
||||
var titleHeight = ImGui.GetCursorPosY() - titleStartY;
|
||||
ImGui.PopTextWrapPos();
|
||||
return titleHeight;
|
||||
return DrawWrappedText(titleText, contentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatTitleText(LightlessNotification notification)
|
||||
{
|
||||
if (!_configService.Current.ShowNotificationTimestamp)
|
||||
return notification.Title;
|
||||
|
||||
var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss");
|
||||
return $"[{timestamp}] {notification.Title}";
|
||||
}
|
||||
|
||||
private float DrawWrappedText(string text, float wrapWidth)
|
||||
{
|
||||
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth);
|
||||
var startY = ImGui.GetCursorPosY();
|
||||
ImGui.TextWrapped(text);
|
||||
var height = ImGui.GetCursorPosY() - startY;
|
||||
ImGui.PopTextWrapPos();
|
||||
return height;
|
||||
}
|
||||
|
||||
private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha)
|
||||
{
|
||||
if (string.IsNullOrEmpty(notification.Message)) return;
|
||||
|
||||
ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f));
|
||||
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha)))
|
||||
var messagePos = contentPos + new Vector2(0f, titleHeight + _titleMessageSpacing);
|
||||
var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha);
|
||||
|
||||
ImGui.SetCursorPos(messagePos);
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, messageColor))
|
||||
{
|
||||
ImGui.TextWrapped(notification.Message);
|
||||
DrawWrappedText(notification.Message, contentWidth);
|
||||
}
|
||||
ImGui.PopTextWrapPos();
|
||||
}
|
||||
|
||||
private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha)
|
||||
{
|
||||
var buttonSpacing = 8f;
|
||||
var rightPadding = 10f;
|
||||
var usableWidth = availableWidth - rightPadding;
|
||||
var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing;
|
||||
var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count;
|
||||
var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth);
|
||||
|
||||
_logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}",
|
||||
notification.Actions.Count, buttonWidth, availableWidth);
|
||||
|
||||
var startCursorPos = ImGui.GetCursorPos();
|
||||
var startX = ImGui.GetCursorPosX();
|
||||
|
||||
for (int i = 0; i < notification.Actions.Count; i++)
|
||||
{
|
||||
var action = notification.Actions[i];
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing);
|
||||
ImGui.SetCursorPosX(currentX);
|
||||
PositionActionButton(i, startX, buttonWidth);
|
||||
}
|
||||
DrawActionButton(action, notification, alpha, buttonWidth);
|
||||
DrawActionButton(notification.Actions[i], notification, alpha, buttonWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateActionButtonWidth(int actionCount, float availableWidth)
|
||||
{
|
||||
var totalSpacing = (actionCount - 1) * _actionButtonSpacing;
|
||||
return (availableWidth - totalSpacing) / actionCount;
|
||||
}
|
||||
|
||||
private void PositionActionButton(int index, float startX, float buttonWidth)
|
||||
{
|
||||
var xPosition = startX + index * (buttonWidth + _actionButtonSpacing);
|
||||
ImGui.SetCursorPosX(xPosition);
|
||||
}
|
||||
|
||||
private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth)
|
||||
{
|
||||
@@ -543,7 +699,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
|
||||
private float CalculateNotificationHeight(LightlessNotification notification)
|
||||
{
|
||||
var contentWidth = _configService.Current.NotificationWidth - 35f;
|
||||
var contentWidth = CalculateContentWidth(_configService.Current.NotificationWidth);
|
||||
var height = 12f;
|
||||
|
||||
height += CalculateTitleHeight(notification, contentWidth);
|
||||
@@ -561,7 +717,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
height += 12f;
|
||||
}
|
||||
|
||||
return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight);
|
||||
return Math.Clamp(height, _notificationMinHeight, _notificationMaxHeight);
|
||||
}
|
||||
|
||||
private float CalculateTitleHeight(LightlessNotification notification, float contentWidth)
|
||||
@@ -590,6 +746,8 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||
NotificationType.Error => UIColors.Get("DimRed"),
|
||||
NotificationType.PairRequest => UIColors.Get("LightlessBlue"),
|
||||
NotificationType.Download => UIColors.Get("LightlessGreen"),
|
||||
NotificationType.Performance => UIColors.Get("LightlessOrange"),
|
||||
|
||||
_ => UIColors.Get("LightlessPurple")
|
||||
};
|
||||
}
|
||||
|
||||
43
LightlessSync/UI/Models/Changelog.cs
Normal file
43
LightlessSync/UI/Models/Changelog.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace LightlessSync.UI.Models
|
||||
{
|
||||
public class ChangelogFile
|
||||
{
|
||||
public string Tagline { get; init; } = string.Empty;
|
||||
public string Subline { get; init; } = string.Empty;
|
||||
public List<ChangelogEntry> Changelog { get; init; } = new();
|
||||
public List<CreditCategory>? Credits { get; init; }
|
||||
}
|
||||
|
||||
public class ChangelogEntry
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Date { get; init; } = string.Empty;
|
||||
public string Tagline { get; init; } = string.Empty;
|
||||
public bool? IsCurrent { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public List<ChangelogVersion>? Versions { get; init; }
|
||||
}
|
||||
|
||||
public class ChangelogVersion
|
||||
{
|
||||
public string Number { get; init; } = string.Empty;
|
||||
public List<string> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public class CreditCategory
|
||||
{
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public List<CreditItem> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public class CreditItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Role { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CreditsFile
|
||||
{
|
||||
public List<CreditCategory> Credits { get; init; } = new();
|
||||
}
|
||||
}
|
||||
6
LightlessSync/UI/Models/GroupFolder.cs
Normal file
6
LightlessSync/UI/Models/GroupFolder.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.UI.Components;
|
||||
|
||||
namespace LightlessSync.UI.Models;
|
||||
|
||||
public record GroupFolder(GroupFullInfoDto GroupFullInfo, IDrawFolder GroupDrawFolder);
|
||||
@@ -85,7 +85,7 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
||||
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(_pair.UserData);
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(_pair.UserData);
|
||||
|
||||
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
|
||||
{
|
||||
|
||||
12
LightlessSync/UI/ProfileTags.cs
Normal file
12
LightlessSync/UI/ProfileTags.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LightlessSync.UI
|
||||
{
|
||||
public enum ProfileTags
|
||||
{
|
||||
SFW = 0,
|
||||
NSFW = 1,
|
||||
RP = 2,
|
||||
ERP = 3,
|
||||
Venues = 4,
|
||||
Gpose = 5
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
|
||||
private readonly NameplateService _nameplateService;
|
||||
private readonly NameplateHandler _nameplateHandler;
|
||||
private readonly NotificationService _lightlessNotificationService;
|
||||
private (int, int, FileCacheEntity) _currentProgress;
|
||||
private bool _deleteAccountPopupModalShown = false;
|
||||
private bool _deleteFilesPopupModalShown = false;
|
||||
@@ -107,8 +106,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
IpcManager ipcManager, CacheMonitor cacheMonitor,
|
||||
DalamudUtilService dalamudUtilService, HttpClient httpClient,
|
||||
NameplateService nameplateService,
|
||||
NameplateHandler nameplateHandler,
|
||||
NotificationService lightlessNotificationService) : base(logger, mediator, "Lightless Sync Settings",
|
||||
NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings",
|
||||
performanceCollector)
|
||||
{
|
||||
_configService = configService;
|
||||
@@ -130,7 +128,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
_uiShared = uiShared;
|
||||
_nameplateService = nameplateService;
|
||||
_nameplateHandler = nameplateHandler;
|
||||
_lightlessNotificationService = lightlessNotificationService;
|
||||
AllowClickthrough = false;
|
||||
AllowPinning = true;
|
||||
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
|
||||
@@ -140,6 +137,25 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
MinimumSize = new Vector2(800, 400), MaximumSize = new Vector2(800, 2000),
|
||||
};
|
||||
|
||||
TitleBarButtons = new()
|
||||
{
|
||||
new TitleBarButton()
|
||||
{
|
||||
Icon = FontAwesomeIcon.FileAlt,
|
||||
Click = (msg) =>
|
||||
{
|
||||
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
|
||||
},
|
||||
IconOffset = new(2, 1),
|
||||
ShowTooltip = () =>
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.Text("View Update Notes");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Mediator.Subscribe<OpenSettingsUiMessage>(this, (_) => Toggle());
|
||||
Mediator.Subscribe<OpenLightfinderSettingsMessage>(this, (_) =>
|
||||
{
|
||||
@@ -591,6 +607,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter;
|
||||
bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload;
|
||||
int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes;
|
||||
bool enableDirectDownloads = _configService.Current.EnableDirectDownloads;
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Global Download Speed Limit");
|
||||
@@ -622,6 +639,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("0 = No limit/infinite");
|
||||
|
||||
if (ImGui.Checkbox("[BETA] Enable Lightspeed Downloads", ref enableDirectDownloads))
|
||||
{
|
||||
_configService.Current.EnableDirectDownloads = enableDirectDownloads;
|
||||
_configService.Save();
|
||||
}
|
||||
_uiShared.DrawHelpText("Uses signed CDN links when available. Disable to force the legacy queued download flow.");
|
||||
|
||||
if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10))
|
||||
{
|
||||
_configService.Current.ParallelDownloads = maxParallelDownloads;
|
||||
@@ -1203,16 +1227,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.TextUnformatted($"Currently utilized local storage: Calculating...");
|
||||
ImGui.TextUnformatted(
|
||||
$"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}");
|
||||
|
||||
bool useFileCompactor = _configService.Current.UseCompactor;
|
||||
bool isLinux = _dalamudUtilService.IsWine;
|
||||
if (!useFileCompactor && !isLinux)
|
||||
if (!useFileCompactor)
|
||||
{
|
||||
UiSharedService.ColorTextWrapped(
|
||||
"Hint: To free up space when using Lightless consider enabling the File Compactor",
|
||||
UIColors.Get("LightlessYellow"));
|
||||
}
|
||||
|
||||
if (isLinux || !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled();
|
||||
if (!_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled();
|
||||
if (ImGui.Checkbox("Use file compactor", ref useFileCompactor))
|
||||
{
|
||||
_configService.Current.UseCompactor = useFileCompactor;
|
||||
@@ -1257,10 +1281,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
UIColors.Get("LightlessYellow"));
|
||||
}
|
||||
|
||||
if (isLinux || !_cacheMonitor.StorageisNTFS)
|
||||
if (!_cacheMonitor.StorageisNTFS)
|
||||
{
|
||||
ImGui.EndDisabled();
|
||||
ImGui.TextUnformatted("The file compactor is only available on Windows and NTFS drives.");
|
||||
ImGui.TextUnformatted("The file compactor is only available NTFS drives, soon for btrfs.");
|
||||
}
|
||||
|
||||
if (_cacheMonitor.StorageisNTFS)
|
||||
{
|
||||
ImGui.TextUnformatted("The file compactor detected an NTFS Drive.");
|
||||
}
|
||||
|
||||
if (_cacheMonitor.StorageIsBtrfs)
|
||||
{
|
||||
ImGui.TextUnformatted("The file compactor detected an Btrfs Drive.");
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(new Vector2(10, 10));
|
||||
@@ -1990,7 +2024,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
|
||||
("LightlessGreen", "Success Green", "Join buttons and success messages"),
|
||||
("LightlessYellow", "Warning Yellow", "Warning colors"),
|
||||
("LightlessYellow2", "Warning Yellow (Alt)", "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")
|
||||
};
|
||||
@@ -2294,7 +2328,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles))
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage());
|
||||
Mediator.Publish(new ClearProfileUserDataMessage());
|
||||
_configService.Current.ProfilesShow = showProfiles;
|
||||
_configService.Save();
|
||||
}
|
||||
@@ -2321,7 +2355,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.Unindent();
|
||||
if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles))
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage());
|
||||
Mediator.Publish(new ClearProfileUserDataMessage());
|
||||
_configService.Current.ProfilesAllowNsfw = showNsfwProfiles;
|
||||
_configService.Save();
|
||||
}
|
||||
@@ -2331,9 +2365,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
}
|
||||
|
||||
private void DrawPerformance()
|
||||
@@ -3091,22 +3123,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
UiSharedService.TooltipSeparator
|
||||
+ "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling");
|
||||
|
||||
if (_dalamudUtilService.IsWine)
|
||||
{
|
||||
bool forceWebSockets = selectedServer.ForceWebSockets;
|
||||
if (ImGui.Checkbox("[wine only] Force WebSockets", ref forceWebSockets))
|
||||
{
|
||||
selectedServer.ForceWebSockets = forceWebSockets;
|
||||
_serverConfigurationManager.Save();
|
||||
}
|
||||
|
||||
_uiShared.DrawHelpText(
|
||||
"On wine, Lightless will automatically fall back to ServerSentEvents/LongPolling, even if WebSockets is selected. "
|
||||
+ "WebSockets are known to crash XIV entirely on wine 8.5 shipped with Dalamud. "
|
||||
+ "Only enable this if you are not running wine 8.5." + Environment.NewLine
|
||||
+ "Note: If the issue gets resolved at some point this option will be removed.");
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth))
|
||||
@@ -3493,69 +3509,178 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
if (useLightlessNotifications)
|
||||
{
|
||||
// Lightless notification locations
|
||||
ImGui.Indent();
|
||||
|
||||
var lightlessLocations = GetLightlessNotificationLocations();
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Info Notifications:");
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
_uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) =>
|
||||
{
|
||||
_configService.Current.LightlessInfoNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessInfoNotification);
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Warning Notifications:");
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
_uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessWarningNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessWarningNotification);
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Error Notifications:");
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
_uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) =>
|
||||
{
|
||||
_configService.Current.LightlessErrorNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessErrorNotification);
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
_uiShared.DrawHelpText("Special notification types:");
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Pair Request Notifications:");
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
_uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessPairRequestNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessPairRequestNotification);
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Download Progress Notifications:");
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
var downloadLocations = GetDownloadNotificationLocations();
|
||||
_uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
|
||||
if (ImGui.BeginTable("##NotificationLocationTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
|
||||
{
|
||||
ImGui.TableSetupColumn("Notification Type", ImGuiTableColumnFlags.WidthFixed, 200f * ImGuiHelpers.GlobalScale);
|
||||
ImGui.TableSetupColumn("Location", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("Test", ImGuiTableColumnFlags.WidthFixed, 40f * ImGuiHelpers.GlobalScale);
|
||||
ImGui.TableHeadersRow();
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Info Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) =>
|
||||
{
|
||||
_configService.Current.LightlessDownloadNotification = location;
|
||||
_configService.Current.LightlessInfoNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessDownloadNotification);
|
||||
}, _configService.Current.LightlessInfoNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
var availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_info", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Info",
|
||||
"This is a test info notification to let you know Chocola is cute :3", NotificationType.Info));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test info notification");
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Warning Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessWarningNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessWarningNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_warning", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!",
|
||||
NotificationType.Warning));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test warning notification");
|
||||
|
||||
ImGui.Unindent();
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Error Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) =>
|
||||
{
|
||||
_configService.Current.LightlessErrorNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessErrorNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_error", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!",
|
||||
NotificationType.Error));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test error notification");
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Pair Request Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessPairRequestNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessPairRequestNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_pair", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
Mediator.Publish(new PairRequestReceivedMessage("test-uid-123", "Test User wants to pair with you."));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test pair request notification");
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Download Progress Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessDownloadNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessDownloadNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_download", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
Mediator.Publish(new PairDownloadStatusMessage(
|
||||
[
|
||||
("Player One", 0.35f, "downloading"),
|
||||
("Player Two", 0.75f, "downloading"),
|
||||
("Player Three", 1.0f, "downloading")
|
||||
],
|
||||
2
|
||||
));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test download progress notification");
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableSetColumnIndex(0);
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Performance Notifications");
|
||||
ImGui.TableSetColumnIndex(1);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
_uiShared.DrawCombo("###enhanced_performance", lightlessLocations, GetNotificationLocationLabel,
|
||||
(location) =>
|
||||
{
|
||||
_configService.Current.LightlessPerformanceNotification = location;
|
||||
_configService.Save();
|
||||
}, _configService.Current.LightlessPerformanceNotification);
|
||||
ImGui.TableSetColumnIndex(2);
|
||||
availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_performance", new Vector2(availableWidth, 0)))
|
||||
{
|
||||
var testUserData = new UserData("TEST123", "TestUser", false, false, false, null, null);
|
||||
Mediator.Publish(new PerformanceNotificationMessage(
|
||||
"Test Player (TestUser) exceeds performance threshold(s)",
|
||||
"Player Test Player (TestUser) exceeds your configured VRAM warning threshold\n500 MB/300 MB",
|
||||
testUserData,
|
||||
false,
|
||||
"Test Player"
|
||||
));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("Test performance notification");
|
||||
|
||||
ImGui.EndTable();
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear All Notifications"))
|
||||
{
|
||||
Mediator.Publish(new ClearAllNotificationsMessage());
|
||||
}
|
||||
_uiShared.DrawHelpText("Dismiss all active notifications immediately.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -3602,73 +3727,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.Separator();
|
||||
if (useLightlessNotifications)
|
||||
{
|
||||
if (_uiShared.MediumTreeNode("Test Notifications", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
ImGui.Indent();
|
||||
|
||||
// Test notification buttons
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.Bell, "Test Info"))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Info",
|
||||
"This is a test info notification to let you know Chocola is cute :3", NotificationType.Info));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationTriangle, "Test Warning"))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!",
|
||||
NotificationType.Warning));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationCircle, "Test Error"))
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!",
|
||||
NotificationType.Error));
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.UserPlus, "Test Pair Request"))
|
||||
{
|
||||
_lightlessNotificationService.ShowPairRequestNotification(
|
||||
"Test User",
|
||||
"test-uid-123",
|
||||
() =>
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.",
|
||||
NotificationType.Info));
|
||||
},
|
||||
() =>
|
||||
{
|
||||
Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.Download, "Test Download Progress"))
|
||||
{
|
||||
_lightlessNotificationService.ShowPairDownloadNotification(
|
||||
new List<(string playerName, float progress, string status)>
|
||||
{
|
||||
("Player One", 0.35f, "downloading"),
|
||||
("Player Two", 0.75f, "downloading"),
|
||||
("Player Three", 1.0f, "downloading")
|
||||
},
|
||||
queueWaiting: 2
|
||||
);
|
||||
}
|
||||
|
||||
_uiShared.DrawHelpText("Preview how notifications will appear with your current settings.");
|
||||
|
||||
ImGui.Unindent();
|
||||
|
||||
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
if (_uiShared.MediumTreeNode("Basic Settings", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
int maxNotifications = _configService.Current.MaxSimultaneousNotifications;
|
||||
@@ -3768,10 +3826,28 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.Spacing();
|
||||
ImGui.TextUnformatted("Position");
|
||||
|
||||
int offsetY = _configService.Current.NotificationOffsetY;
|
||||
if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 500))
|
||||
var currentCorner = _configService.Current.NotificationCorner;
|
||||
if (ImGui.BeginCombo("Notification Position", GetNotificationCornerLabel(currentCorner)))
|
||||
{
|
||||
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 500);
|
||||
foreach (NotificationCorner corner in Enum.GetValues(typeof(NotificationCorner)))
|
||||
{
|
||||
bool isSelected = currentCorner == corner;
|
||||
if (ImGui.Selectable(GetNotificationCornerLabel(corner), isSelected))
|
||||
{
|
||||
_configService.Current.NotificationCorner = corner;
|
||||
_configService.Save();
|
||||
}
|
||||
if (isSelected)
|
||||
ImGui.SetItemDefaultFocus();
|
||||
}
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
_uiShared.DrawHelpText("Choose which corner of the screen notifications appear in.");
|
||||
|
||||
int offsetY = _configService.Current.NotificationOffsetY;
|
||||
if (ImGui.SliderInt("Vertical Offset", ref offsetY, -2500, 2500))
|
||||
{
|
||||
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, -2500, 2500);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
@@ -3781,12 +3857,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Right click to reset to default (50).");
|
||||
_uiShared.DrawHelpText("Move notifications down from the top-right corner.");
|
||||
_uiShared.DrawHelpText("Distance from the top edge of the screen.");
|
||||
|
||||
int offsetX = _configService.Current.NotificationOffsetX;
|
||||
if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500))
|
||||
if (ImGui.SliderInt("Horizontal Offset", ref offsetX, -2500, 2500))
|
||||
{
|
||||
_configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 500);
|
||||
_configService.Current.NotificationOffsetX = Math.Clamp(offsetX, -2500, 2500);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
@@ -3802,9 +3878,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.TextUnformatted("Animation Settings");
|
||||
|
||||
float animSpeed = _configService.Current.NotificationAnimationSpeed;
|
||||
if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 30f, "%.1f"))
|
||||
if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 20f, "%.1f"))
|
||||
{
|
||||
_configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 30f);
|
||||
_configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 20f);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
@@ -3816,6 +3892,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetTooltip("Right click to reset to default (10).");
|
||||
_uiShared.DrawHelpText("How fast notifications slide in/out. Higher = faster.");
|
||||
|
||||
float slideSpeed = _configService.Current.NotificationSlideSpeed;
|
||||
if (ImGui.SliderFloat("Slide Speed", ref slideSpeed, 1f, 20f, "%.1f"))
|
||||
{
|
||||
_configService.Current.NotificationSlideSpeed = Math.Clamp(slideSpeed, 1f, 20f);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
{
|
||||
_configService.Current.NotificationSlideSpeed = 10f;
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Right click to reset to default (10).");
|
||||
_uiShared.DrawHelpText("How fast notifications slide into position when others disappear. Higher = faster.");
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.TextUnformatted("Visual Effects");
|
||||
|
||||
@@ -3888,9 +3979,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetTooltip("Right click to reset to default (20).");
|
||||
|
||||
int pairRequestDuration = _configService.Current.PairRequestDurationSeconds;
|
||||
if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 600))
|
||||
if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 1800))
|
||||
{
|
||||
_configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 600);
|
||||
_configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 1800);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
@@ -3902,18 +3993,32 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetTooltip("Right click to reset to default (180).");
|
||||
|
||||
int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds;
|
||||
if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 600))
|
||||
if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 15, 120))
|
||||
{
|
||||
_configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 600);
|
||||
_configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 15, 120);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
{
|
||||
_configService.Current.DownloadNotificationDurationSeconds = 300;
|
||||
_configService.Current.DownloadNotificationDurationSeconds = 30;
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Right click to reset to default (300).");
|
||||
ImGui.SetTooltip("Right click to reset to default (30).");
|
||||
|
||||
int performanceDuration = _configService.Current.PerformanceNotificationDurationSeconds;
|
||||
if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 120))
|
||||
{
|
||||
_configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 120);
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
|
||||
{
|
||||
_configService.Current.PerformanceNotificationDurationSeconds = 20;
|
||||
_configService.Save();
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Right click to reset to default (20).");
|
||||
|
||||
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
@@ -3982,6 +4087,38 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.TreePop();
|
||||
}
|
||||
|
||||
if (_uiShared.MediumTreeNode("Pair Request Notifications", UIColors.Get("PairBlue")))
|
||||
{
|
||||
var showPairRequestActions = _configService.Current.ShowPairRequestNotificationActions;
|
||||
if (ImGui.Checkbox("Show action buttons on pair requests", ref showPairRequestActions))
|
||||
{
|
||||
_configService.Current.ShowPairRequestNotificationActions = showPairRequestActions;
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
_uiShared.DrawHelpText(
|
||||
"When you receive a pair request, show Accept/Decline buttons in the notification.");
|
||||
|
||||
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
|
||||
if (_uiShared.MediumTreeNode("Performance Notifications", UIColors.Get("LightlessOrange")))
|
||||
{
|
||||
var showPerformanceActions = _configService.Current.ShowPerformanceNotificationActions;
|
||||
if (ImGui.Checkbox("Show action buttons on performance warnings", ref showPerformanceActions))
|
||||
{
|
||||
_configService.Current.ShowPerformanceNotificationActions = showPerformanceActions;
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
_uiShared.DrawHelpText(
|
||||
"When a player exceeds performance thresholds or is auto-paused, show Pause/Unpause buttons in the notification.");
|
||||
|
||||
_uiShared.ColoredSeparator(UIColors.Get("LightlessOrange"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
|
||||
if (_uiShared.MediumTreeNode("System Notifications", UIColors.Get("LightlessYellow")))
|
||||
{
|
||||
var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings;
|
||||
@@ -3999,6 +4136,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
ImGui.Separator();
|
||||
// Location descriptions removed - information is now inline with each setting
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4006,7 +4144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere
|
||||
NotificationLocation.LightlessUi, NotificationLocation.Chat, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4014,8 +4152,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi,
|
||||
NotificationLocation.TextOverlay, NotificationLocation.Nowhere
|
||||
NotificationLocation.LightlessUi, NotificationLocation.TextOverlay, NotificationLocation.Nowhere
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4043,6 +4180,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
};
|
||||
}
|
||||
|
||||
private string GetNotificationCornerLabel(NotificationCorner corner)
|
||||
{
|
||||
return corner switch
|
||||
{
|
||||
NotificationCorner.Right => "Right",
|
||||
NotificationCorner.Left => "Left",
|
||||
_ => corner.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private void DrawSoundTable()
|
||||
{
|
||||
var soundEffects = new[]
|
||||
@@ -4068,7 +4215,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u),
|
||||
("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u),
|
||||
("Pair Request", 3, _configService.Current.PairRequestSoundId, _configService.Current.DisablePairRequestSound, 5u),
|
||||
("Download", 4, _configService.Current.DownloadSoundId, _configService.Current.DisableDownloadSound, 15u)
|
||||
("Performance", 4, _configService.Current.PerformanceSoundId, _configService.Current.DisablePerformanceSound, 16u)
|
||||
};
|
||||
|
||||
foreach (var (typeName, typeIndex, currentSoundId, isDisabled, defaultSoundId) in soundTypes)
|
||||
@@ -4087,7 +4234,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
var currentIndex = Array.FindIndex(soundEffects, s => s.Item1 == currentSoundId);
|
||||
if (currentIndex == -1) currentIndex = 1;
|
||||
|
||||
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex,
|
||||
soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length))
|
||||
{
|
||||
@@ -4098,7 +4245,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
case 1: _configService.Current.CustomWarningSoundId = newSoundId; break;
|
||||
case 2: _configService.Current.CustomErrorSoundId = newSoundId; break;
|
||||
case 3: _configService.Current.PairRequestSoundId = newSoundId; break;
|
||||
case 4: _configService.Current.DownloadSoundId = newSoundId; break;
|
||||
case 4: _configService.Current.PerformanceSoundId = newSoundId; break;
|
||||
}
|
||||
|
||||
_configService.Save();
|
||||
@@ -4152,7 +4299,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
case 1: _configService.Current.DisableWarningSound = newDisabled; break;
|
||||
case 2: _configService.Current.DisableErrorSound = newDisabled; break;
|
||||
case 3: _configService.Current.DisablePairRequestSound = newDisabled; break;
|
||||
case 4: _configService.Current.DisableDownloadSound = newDisabled; break;
|
||||
case 4: _configService.Current.DisablePerformanceSound = newDisabled; break;
|
||||
}
|
||||
_configService.Save();
|
||||
}
|
||||
@@ -4178,7 +4325,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break;
|
||||
case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break;
|
||||
case 3: _configService.Current.PairRequestSoundId = defaultSoundId; break;
|
||||
case 4: _configService.Current.DownloadSoundId = defaultSoundId; break;
|
||||
case 4: _configService.Current.PerformanceSoundId = defaultSoundId; break;
|
||||
}
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
||||
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(Pair.UserData);
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(Pair.UserData);
|
||||
|
||||
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
|
||||
{
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
@@ -22,29 +31,51 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
private readonly bool _isOwner = false;
|
||||
private readonly List<string> _oneTimeInvites = [];
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private List<BannedGroupUserDto> _bannedUsers = [];
|
||||
private LightlessGroupProfileData? _profileData = null;
|
||||
private bool _adjustedForScollBarsLocalProfile = false;
|
||||
private bool _adjustedForScollBarsOnlineProfile = false;
|
||||
private string _descriptionText = string.Empty;
|
||||
private IDalamudTextureWrap? _pfpTextureWrap;
|
||||
private string _profileDescription = string.Empty;
|
||||
private byte[] _profileImage = [];
|
||||
private bool _showFileDialogError = false;
|
||||
private int _multiInvites;
|
||||
private string _newPassword;
|
||||
private bool _pwChangeSuccess;
|
||||
private Task<int>? _pruneTestTask;
|
||||
private Task<int>? _pruneTask;
|
||||
private int _pruneDays = 14;
|
||||
private List<int> _selectedTags = [];
|
||||
|
||||
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
|
||||
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService)
|
||||
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
|
||||
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
|
||||
{
|
||||
GroupFullInfo = groupFullInfo;
|
||||
_apiController = apiController;
|
||||
_uiSharedService = uiSharedService;
|
||||
_pairManager = pairManager;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_fileDialogManager = fileDialogManager;
|
||||
|
||||
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
|
||||
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
|
||||
_newPassword = string.Empty;
|
||||
_multiInvites = 30;
|
||||
_pwChangeSuccess = true;
|
||||
IsOpen = true;
|
||||
Mediator.Subscribe<ClearProfileGroupDataMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal))
|
||||
{
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = null;
|
||||
}
|
||||
});
|
||||
SizeConstraints = new WindowSizeConstraints()
|
||||
{
|
||||
MinimumSize = new(700, 500),
|
||||
@@ -58,10 +89,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
if (!_isModerator && !_isOwner) return;
|
||||
|
||||
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
|
||||
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group];
|
||||
|
||||
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
|
||||
_profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
|
||||
GetTagsFromProfile();
|
||||
|
||||
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
_uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue"));
|
||||
|
||||
@@ -69,14 +103,16 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
var perm = GroupFullInfo.GroupPermissions;
|
||||
|
||||
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
|
||||
|
||||
|
||||
if (tabbar)
|
||||
{
|
||||
DrawInvites(perm);
|
||||
|
||||
DrawManagement();
|
||||
|
||||
DrawPermission(perm);
|
||||
DrawPermission(perm);
|
||||
|
||||
DrawProfile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +212,184 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
ownerTab.Dispose();
|
||||
}
|
||||
}
|
||||
private void DrawProfile()
|
||||
{
|
||||
var profileTab = ImRaii.TabItem("Profile");
|
||||
|
||||
if (profileTab)
|
||||
{
|
||||
if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
if (!_profileImage.SequenceEqual(_profileData.ImageData.Value))
|
||||
{
|
||||
_profileImage = _profileData.ImageData.Value;
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
|
||||
}
|
||||
|
||||
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_profileDescription = _profileData.Description;
|
||||
_descriptionText = _profileDescription;
|
||||
}
|
||||
|
||||
if (_pfpTextureWrap != null)
|
||||
{
|
||||
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
|
||||
}
|
||||
|
||||
var spacing = ImGui.GetStyle().ItemSpacing.X;
|
||||
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f);
|
||||
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
|
||||
if (descriptionTextSize.Y > childFrame.Y)
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = false;
|
||||
}
|
||||
childFrame = childFrame with
|
||||
{
|
||||
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(101, childFrame))
|
||||
{
|
||||
UiSharedService.TextWrapped(_profileData.Description);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
ImGui.TreePop();
|
||||
}
|
||||
var nsfw = _profileData.IsNsfw;
|
||||
ImGui.BeginDisabled();
|
||||
ImGui.Checkbox("Is NSFW", ref nsfw);
|
||||
ImGui.EndDisabled();
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
ImGui.TextUnformatted($"Profile Picture:");
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
|
||||
{
|
||||
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
|
||||
{
|
||||
if (!success) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false);
|
||||
MemoryStream ms = new(fileContent);
|
||||
await using (ms.ConfigureAwait(false))
|
||||
{
|
||||
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
|
||||
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
}
|
||||
using var image = Image.Load<Rgba32>(fileContent);
|
||||
|
||||
if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024))
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_showFileDialogError = false;
|
||||
await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), BannerBase64: null, IsNsfw: null, IsDisabled: null))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
UiSharedService.AttachToolTip("Select and upload a new profile picture");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
|
||||
if (_showFileDialogError)
|
||||
{
|
||||
UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed);
|
||||
}
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Tags:");
|
||||
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
|
||||
|
||||
var allCategoryIndexes = Enum.GetValues<ProfileTags>()
|
||||
.Cast<int>()
|
||||
.ToList();
|
||||
|
||||
foreach(int tag in allCategoryIndexes)
|
||||
{
|
||||
using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag);
|
||||
}
|
||||
ImGui.Separator();
|
||||
var widthTextBox = 400;
|
||||
var posX = ImGui.GetCursorPosX();
|
||||
ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500");
|
||||
ImGui.SetCursorPosX(posX);
|
||||
ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X);
|
||||
ImGui.TextUnformatted("Preview (approximate)");
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200));
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
|
||||
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
|
||||
{
|
||||
_adjustedForScollBarsLocalProfile = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_adjustedForScollBarsLocalProfile = false;
|
||||
}
|
||||
childFrameLocal = childFrameLocal with
|
||||
{
|
||||
X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(102, childFrameLocal))
|
||||
{
|
||||
UiSharedService.TextWrapped(_descriptionText);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
}
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Sets your profile description text");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clears your profile description text");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Profile Options:");
|
||||
var isNsfw = _profileData.IsNsfw;
|
||||
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: isNsfw, IsDisabled: null));
|
||||
}
|
||||
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
|
||||
ImGui.TreePop();
|
||||
}
|
||||
}
|
||||
profileTab.Dispose();
|
||||
}
|
||||
|
||||
private void DrawManagement()
|
||||
{
|
||||
@@ -192,7 +406,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp;
|
||||
if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY;
|
||||
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.GID, 3, tableFlags);
|
||||
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.AliasOrGID, 3, tableFlags);
|
||||
if (table)
|
||||
{
|
||||
ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4);
|
||||
@@ -474,7 +688,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
ImGui.Separator();
|
||||
}
|
||||
mgmtTab.Dispose();
|
||||
|
||||
}
|
||||
|
||||
private void DrawInvites(GroupPermissions perm)
|
||||
@@ -521,9 +734,37 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
inviteTab.Dispose();
|
||||
}
|
||||
private void DrawTag(int tag)
|
||||
{
|
||||
var HasTag = _selectedTags.Contains(tag);
|
||||
var tagName = (ProfileTags)tag;
|
||||
|
||||
if (ImGui.Checkbox(tagName.ToString(), ref HasTag))
|
||||
{
|
||||
if (HasTag)
|
||||
{
|
||||
_selectedTags.Add(tag);
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedTags.Remove(tag);
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GetTagsFromProfile()
|
||||
{
|
||||
if (_profileData != null)
|
||||
{
|
||||
_selectedTags = [.. _profileData.Tags];
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
Mediator.Publish(new RemoveWindowMessage(this));
|
||||
_pfpTextureWrap?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
|
||||
ImGuiHelpers.ScaledDummy(0.5f);
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessYellow2"));
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue"));
|
||||
|
||||
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
|
||||
{
|
||||
@@ -288,8 +288,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (updatedList != null)
|
||||
{
|
||||
var previousGid = GetSelectedGid();
|
||||
|
||||
@@ -196,82 +196,6 @@ public class TopTabMenu
|
||||
|
||||
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
|
||||
|
||||
#if DEBUG
|
||||
if (ImGui.Button("Test Pair Request"))
|
||||
{
|
||||
_lightlessNotificationService.ShowPairRequestNotification(
|
||||
"Debug User",
|
||||
"debug-user-id",
|
||||
onAccept: () =>
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Pair Accepted",
|
||||
"Debug pair request was accepted!",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
},
|
||||
onDecline: () =>
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Pair Declined",
|
||||
"Debug pair request was declined.",
|
||||
NotificationType.Warning,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Test Info"))
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Information",
|
||||
"This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Test Warning"))
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Warning",
|
||||
"This is a test warning notification.",
|
||||
NotificationType.Warning,
|
||||
TimeSpan.FromSeconds(7)));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Test Error"))
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Error",
|
||||
"This is a test error notification erp police",
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
if (ImGui.Button("Test Download Progress"))
|
||||
{
|
||||
var downloadStatus = new List<(string playerName, float progress, string status)>
|
||||
{
|
||||
("Mauwmauw Nekochan", 0.85f, "downloading"),
|
||||
("Raelynn Kitsune", 0.34f, "downloading"),
|
||||
("Jaina Elraeth", 0.67f, "downloading"),
|
||||
("Vaelstra Bloodthorn", 0.19f, "downloading"),
|
||||
("Lydia Hera Moondrop", 0.86f, "downloading"),
|
||||
("C'liina Star", 1.0f, "completed")
|
||||
};
|
||||
|
||||
_lightlessNotificationService.ShowPairDownloadNotification(downloadStatus);
|
||||
}
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Dismiss Download"))
|
||||
{
|
||||
_lightlessNotificationService.DismissPairDownloadNotification();
|
||||
}
|
||||
#endif
|
||||
|
||||
DrawIncomingPairRequests(availableWidth);
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
@@ -11,21 +11,17 @@ namespace LightlessSync.UI
|
||||
{ "LightlessPurple", "#ad8af5" },
|
||||
{ "LightlessPurpleActive", "#be9eff" },
|
||||
{ "LightlessPurpleDefault", "#9375d1" },
|
||||
|
||||
{ "ButtonDefault", "#323232" },
|
||||
{ "FullBlack", "#000000" },
|
||||
|
||||
{ "LightlessBlue", "#a6c2ff" },
|
||||
{ "LightlessYellow", "#ffe97a" },
|
||||
{ "LightlessYellow2", "#cfbd63" },
|
||||
{ "LightlessGreen", "#7cd68a" },
|
||||
{ "LightlessOrange", "#ffb366" },
|
||||
{ "PairBlue", "#88a2db" },
|
||||
{ "DimRed", "#d44444" },
|
||||
|
||||
{ "LightlessAdminText", "#ffd663" },
|
||||
{ "LightlessAdminGlow", "#b09343" },
|
||||
{ "LightlessModeratorText", "#94ffda" },
|
||||
{ "LightlessModeratorGlow", "#599c84" },
|
||||
|
||||
{ "Lightfinder", "#ad8af5" },
|
||||
{ "LightfinderEdge", "#000000" },
|
||||
|
||||
767
LightlessSync/UI/UpdateNotesUi.cs
Normal file
767
LightlessSync/UI/UpdateNotesUi.cs
Normal file
@@ -0,0 +1,767 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.UI.Models;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
// Inspiration taken from Brio and Character Select+ (goats)
|
||||
public class UpdateNotesUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
private readonly UiSharedService _uiShared;
|
||||
private readonly LightlessConfigService _configService;
|
||||
|
||||
private ChangelogFile _changelog = new();
|
||||
private CreditsFile _credits = new();
|
||||
private bool _scrollToTop;
|
||||
private int _selectedTab;
|
||||
private bool _hasInitializedCollapsingHeaders;
|
||||
|
||||
private struct Particle
|
||||
{
|
||||
public Vector2 Position;
|
||||
public Vector2 Velocity;
|
||||
public float Life;
|
||||
public float MaxLife;
|
||||
public float Size;
|
||||
public ParticleType Type;
|
||||
public List<Vector2>? Trail;
|
||||
public float Twinkle;
|
||||
public float Depth;
|
||||
public float Hue;
|
||||
}
|
||||
|
||||
private enum ParticleType
|
||||
{
|
||||
TwinklingStar,
|
||||
ShootingStar
|
||||
}
|
||||
|
||||
private readonly List<Particle> _particles = [];
|
||||
private float _particleSpawnTimer;
|
||||
private readonly Random _random = new();
|
||||
|
||||
private const float _headerHeight = 150f;
|
||||
private const float _particleSpawnInterval = 0.2f;
|
||||
private const int _maxParticles = 50;
|
||||
private const int _maxTrailLength = 50;
|
||||
private const float _edgeFadeDistance = 30f;
|
||||
private const float _extendedParticleHeight = 40f;
|
||||
|
||||
public UpdateNotesUi(ILogger<UpdateNotesUi> logger,
|
||||
LightlessMediator mediator,
|
||||
UiSharedService uiShared,
|
||||
LightlessConfigService configService,
|
||||
PerformanceCollectorService performanceCollectorService)
|
||||
: base(logger, mediator, "Lightless Sync — Update Notes", performanceCollectorService)
|
||||
{
|
||||
logger.LogInformation("UpdateNotesUi constructor called");
|
||||
_uiShared = uiShared;
|
||||
_configService = configService;
|
||||
|
||||
AllowClickthrough = false;
|
||||
AllowPinning = false;
|
||||
RespectCloseHotkey = true;
|
||||
ShowCloseButton = true;
|
||||
|
||||
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse |
|
||||
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
|
||||
|
||||
SizeConstraints = new WindowSizeConstraints()
|
||||
{
|
||||
MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700),
|
||||
};
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
LoadEmbeddedResources();
|
||||
logger.LogInformation("UpdateNotesUi constructor completed successfully");
|
||||
}
|
||||
|
||||
public override void OnOpen()
|
||||
{
|
||||
_scrollToTop = true;
|
||||
_hasInitializedCollapsingHeaders = false;
|
||||
}
|
||||
|
||||
private void CenterWindow()
|
||||
{
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
var center = viewport.GetCenter();
|
||||
var windowSize = new Vector2(800f * ImGuiHelpers.GlobalScale, 700f * ImGuiHelpers.GlobalScale);
|
||||
Position = center - windowSize / 2f;
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
if (_uiShared.IsInGpose)
|
||||
return;
|
||||
|
||||
CenterWindow();
|
||||
DrawHeader();
|
||||
ImGuiHelpers.ScaledDummy(6);
|
||||
DrawTabs();
|
||||
DrawCloseButton();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
var windowPos = ImGui.GetWindowPos();
|
||||
var windowPadding = ImGui.GetStyle().WindowPadding;
|
||||
var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2);
|
||||
|
||||
var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y);
|
||||
var headerEnd = headerStart + new Vector2(headerWidth, _headerHeight);
|
||||
|
||||
var extendedParticleSize = new Vector2(headerWidth, _headerHeight + _extendedParticleHeight);
|
||||
|
||||
DrawGradientBackground(headerStart, headerEnd);
|
||||
DrawHeaderText(headerStart);
|
||||
DrawHeaderButtons(headerStart, headerWidth);
|
||||
DrawBottomGradient(headerStart, headerEnd, headerWidth);
|
||||
|
||||
ImGui.SetCursorPosY(windowPadding.Y + _headerHeight + 5);
|
||||
ImGui.SetCursorPosX(20);
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
ImGui.TextColored(UIColors.Get("LightlessGreen"), FontAwesomeIcon.Star.ToIconString());
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.TextColored(UIColors.Get("LightlessGreen"), "What's New");
|
||||
|
||||
if (!string.IsNullOrEmpty(_changelog.Tagline))
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10);
|
||||
ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), _changelog.Tagline);
|
||||
|
||||
if (!string.IsNullOrEmpty(_changelog.Subline))
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.75f, 1.0f), $" – {_changelog.Subline}");
|
||||
}
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
|
||||
DrawParticleEffects(headerStart, extendedParticleSize);
|
||||
}
|
||||
|
||||
private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f);
|
||||
var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f);
|
||||
|
||||
drawList.AddRectFilledMultiColor(
|
||||
headerStart,
|
||||
headerEnd,
|
||||
ImGui.GetColorU32(darkPurple),
|
||||
ImGui.GetColorU32(darkPurple),
|
||||
ImGui.GetColorU32(deepPurple),
|
||||
ImGui.GetColorU32(deepPurple)
|
||||
);
|
||||
|
||||
var random = new Random(42);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var starPos = headerStart + new Vector2(
|
||||
(float)random.NextDouble() * (headerEnd.X - headerStart.X),
|
||||
(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)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var gradientHeight = 60f;
|
||||
|
||||
for (int i = 0; i < gradientHeight; i++)
|
||||
{
|
||||
var progress = i / gradientHeight;
|
||||
var smoothProgress = progress * progress;
|
||||
var r = 0.12f + (0.0f - 0.12f) * smoothProgress;
|
||||
var g = 0.08f + (0.0f - 0.08f) * smoothProgress;
|
||||
var b = 0.20f + (0.0f - 0.20f) * smoothProgress;
|
||||
var alpha = 1f - smoothProgress;
|
||||
var gradientColor = new Vector4(r, g, b, alpha);
|
||||
drawList.AddLine(
|
||||
new Vector2(headerStart.X, headerEnd.Y + i),
|
||||
new Vector2(headerStart.X + width, headerEnd.Y + i),
|
||||
ImGui.GetColorU32(gradientColor),
|
||||
1f
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawHeaderText(Vector2 headerStart)
|
||||
{
|
||||
var textX = 20f;
|
||||
var textY = 30f;
|
||||
|
||||
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY));
|
||||
|
||||
using (_uiShared.UidFont.Push())
|
||||
{
|
||||
ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync");
|
||||
}
|
||||
|
||||
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f));
|
||||
ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes");
|
||||
}
|
||||
|
||||
private void DrawHeaderButtons(Vector2 headerStart, float headerWidth)
|
||||
{
|
||||
var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe);
|
||||
var spacing = 8f * ImGuiHelpers.GlobalScale;
|
||||
var rightPadding = 15f * ImGuiHelpers.GlobalScale;
|
||||
var topPadding = 15f * ImGuiHelpers.GlobalScale;
|
||||
var buttonY = headerStart.Y + topPadding;
|
||||
var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X;
|
||||
var discordButtonX = gitButtonX - buttonSize.X - spacing;
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY));
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0)))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f }))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f }))
|
||||
{
|
||||
if (_uiShared.IconButton(FontAwesomeIcon.Comments))
|
||||
{
|
||||
Util.OpenLink("https://discord.gg/dsbjcXMnhA");
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("Join our Discord");
|
||||
}
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY));
|
||||
if (_uiShared.IconButton(FontAwesomeIcon.Code))
|
||||
{
|
||||
Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync");
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("View on Git");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize)
|
||||
{
|
||||
var deltaTime = ImGui.GetIO().DeltaTime;
|
||||
_particleSpawnTimer += deltaTime;
|
||||
|
||||
if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles)
|
||||
{
|
||||
SpawnParticle(bannerSize);
|
||||
_particleSpawnTimer = 0f;
|
||||
}
|
||||
|
||||
if (_random.NextDouble() < 0.003)
|
||||
{
|
||||
SpawnShootingStar(bannerSize);
|
||||
}
|
||||
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
for (int i = _particles.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var particle = _particles[i];
|
||||
|
||||
var screenPos = bannerStart + particle.Position;
|
||||
|
||||
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null)
|
||||
{
|
||||
particle.Trail.Insert(0, particle.Position);
|
||||
if (particle.Trail.Count > _maxTrailLength)
|
||||
particle.Trail.RemoveAt(particle.Trail.Count - 1);
|
||||
}
|
||||
|
||||
if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
particle.Twinkle += 0.005f * particle.Depth;
|
||||
}
|
||||
|
||||
particle.Position += particle.Velocity * deltaTime;
|
||||
particle.Life -= deltaTime;
|
||||
|
||||
var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 ||
|
||||
particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50;
|
||||
|
||||
if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds))
|
||||
{
|
||||
_particles.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
if (particle.Position.X < 0 || particle.Position.X > bannerSize.X)
|
||||
particle.Velocity = particle.Velocity with { X = -particle.Velocity.X };
|
||||
if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y)
|
||||
particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y };
|
||||
}
|
||||
|
||||
var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f);
|
||||
var fadeOut = Math.Min(1f, particle.Life / 20f);
|
||||
var lifeFade = Math.Min(fadeIn, fadeOut);
|
||||
|
||||
var edgeFadeX = Math.Min(
|
||||
Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance),
|
||||
Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance)
|
||||
);
|
||||
var edgeFadeY = Math.Min(
|
||||
Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance),
|
||||
Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance)
|
||||
);
|
||||
var edgeFade = Math.Min(edgeFadeX, edgeFadeY);
|
||||
|
||||
var baseAlpha = lifeFade * edgeFade;
|
||||
var finalAlpha = particle.Type == ParticleType.TwinklingStar
|
||||
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
||||
: baseAlpha;
|
||||
|
||||
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
|
||||
{
|
||||
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
|
||||
|
||||
for (int t = 1; t < particle.Trail.Count; t++)
|
||||
{
|
||||
var trailProgress = (float)t / particle.Trail.Count;
|
||||
var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f);
|
||||
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 }),
|
||||
trailWidth + 4f
|
||||
);
|
||||
|
||||
drawList.AddLine(
|
||||
bannerStart + particle.Trail[t - 1],
|
||||
bannerStart + particle.Trail[t],
|
||||
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
|
||||
trailWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth);
|
||||
}
|
||||
|
||||
_particles[i] = particle;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha,
|
||||
float depth)
|
||||
{
|
||||
var color = HslToRgb(hue, 1.0f, 0.85f);
|
||||
color.W = alpha;
|
||||
|
||||
drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color));
|
||||
|
||||
var glowColor = color with { W = alpha * 0.3f };
|
||||
drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor));
|
||||
}
|
||||
|
||||
private static Vector4 HslToRgb(float h, float s, float l)
|
||||
{
|
||||
h = h / 360f;
|
||||
float c = (1 - MathF.Abs(2 * l - 1)) * s;
|
||||
float x = c * (1 - MathF.Abs((h * 6) % 2 - 1));
|
||||
float m = l - c / 2;
|
||||
|
||||
float r, g, b;
|
||||
if (h < 1f / 6f)
|
||||
{
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
}
|
||||
else if (h < 2f / 6f)
|
||||
{
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
}
|
||||
else if (h < 3f / 6f)
|
||||
{
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
}
|
||||
else if (h < 4f / 6f)
|
||||
{
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
}
|
||||
else if (h < 5f / 6f)
|
||||
{
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
}
|
||||
else
|
||||
{
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return new Vector4(r + m, g + m, b + m, 1.0f);
|
||||
}
|
||||
|
||||
|
||||
private void SpawnParticle(Vector2 bannerSize)
|
||||
{
|
||||
var position = new Vector2(
|
||||
(float)_random.NextDouble() * bannerSize.X,
|
||||
(float)_random.NextDouble() * bannerSize.Y
|
||||
);
|
||||
|
||||
var depthLayers = new[] { 0.5f, 1.0f, 1.5f };
|
||||
var depth = depthLayers[_random.Next(depthLayers.Length)];
|
||||
|
||||
var velocity = new Vector2(
|
||||
((float)_random.NextDouble() - 0.5f) * 0.05f * depth,
|
||||
((float)_random.NextDouble() - 0.5f) * 0.05f * depth
|
||||
);
|
||||
|
||||
var isBlue = _random.NextDouble() < 0.5;
|
||||
var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f;
|
||||
var size = (0.5f + (float)_random.NextDouble() * 2f) * depth;
|
||||
var maxLife = 120f + (float)_random.NextDouble() * 60f;
|
||||
|
||||
_particles.Add(new Particle
|
||||
{
|
||||
Position = position,
|
||||
Velocity = velocity,
|
||||
Life = maxLife,
|
||||
MaxLife = maxLife,
|
||||
Size = size,
|
||||
Type = ParticleType.TwinklingStar,
|
||||
Trail = null,
|
||||
Twinkle = (float)_random.NextDouble() * MathF.PI * 2,
|
||||
Depth = depth,
|
||||
Hue = hue
|
||||
});
|
||||
}
|
||||
|
||||
private void SpawnShootingStar(Vector2 bannerSize)
|
||||
{
|
||||
var maxLife = 80f + (float)_random.NextDouble() * 40f;
|
||||
var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f);
|
||||
var startY = -10f;
|
||||
|
||||
_particles.Add(new Particle
|
||||
{
|
||||
Position = new Vector2(startX, startY),
|
||||
Velocity = new Vector2(
|
||||
-50f - (float)_random.NextDouble() * 40f,
|
||||
30f + (float)_random.NextDouble() * 40f
|
||||
),
|
||||
Life = maxLife,
|
||||
MaxLife = maxLife,
|
||||
Size = 2.5f,
|
||||
Type = ParticleType.ShootingStar,
|
||||
Trail = new List<Vector2>(),
|
||||
Twinkle = 0,
|
||||
Depth = 1.0f,
|
||||
Hue = 270f
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void DrawTabs()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Tab, UIColors.Get("ButtonDefault")))
|
||||
using (ImRaii.PushColor(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushColor(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive")))
|
||||
{
|
||||
using (var tabBar = ImRaii.TabBar("###ll_tabs", ImGuiTabBarFlags.None))
|
||||
{
|
||||
if (!tabBar)
|
||||
return;
|
||||
|
||||
using (var changelogTab = ImRaii.TabItem("Changelog"))
|
||||
{
|
||||
if (changelogTab)
|
||||
{
|
||||
_selectedTab = 0;
|
||||
DrawChangelog();
|
||||
}
|
||||
}
|
||||
|
||||
if (_credits.Credits != null && _credits.Credits.Count > 0)
|
||||
{
|
||||
using (var creditsTab = ImRaii.TabItem("Credits"))
|
||||
{
|
||||
if (creditsTab)
|
||||
{
|
||||
_selectedTab = 1;
|
||||
DrawCredits();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCredits()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
|
||||
using (var child = ImRaii.Child("###ll_credits", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
|
||||
ImGuiWindowFlags.AlwaysVerticalScrollbar))
|
||||
{
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
ImGui.PushTextWrapPos();
|
||||
|
||||
if (_credits.Credits != null)
|
||||
{
|
||||
foreach (var category in _credits.Credits)
|
||||
{
|
||||
DrawCreditCategory(category);
|
||||
ImGuiHelpers.ScaledDummy(10);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCreditCategory(CreditCategory category)
|
||||
{
|
||||
DrawFeatureSection(category.Category, UIColors.Get("LightlessBlue"));
|
||||
|
||||
foreach (var item in category.Items)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Role))
|
||||
{
|
||||
ImGui.BulletText($"{item.Name} — {item.Role}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.BulletText(item.Name);
|
||||
}
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
}
|
||||
|
||||
private void DrawCloseButton()
|
||||
{
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
var closeWidth = 200f * ImGuiHelpers.GlobalScale;
|
||||
var closeHeight = 35f * ImGuiHelpers.GlobalScale;
|
||||
ImGui.SetCursorPosX((ImGui.GetWindowSize().X - closeWidth) / 2);
|
||||
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 8f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurpleActive")))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("ButtonDefault")))
|
||||
{
|
||||
if (ImGui.Button("Got it!", new Vector2(closeWidth, closeHeight)))
|
||||
{
|
||||
// Update last seen version when user acknowledges the update notes
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
|
||||
_configService.Current.LastSeenVersion = currentVersion;
|
||||
_configService.Save();
|
||||
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("You can view this window again in the settings (title menu)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChangelog()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
|
||||
using (var child = ImRaii.Child("###ll_changelog", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
|
||||
ImGuiWindowFlags.AlwaysVerticalScrollbar))
|
||||
{
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
if (_scrollToTop)
|
||||
{
|
||||
_scrollToTop = false;
|
||||
ImGui.SetScrollHereY(0);
|
||||
}
|
||||
|
||||
ImGui.PushTextWrapPos();
|
||||
|
||||
foreach (var entry in _changelog.Changelog)
|
||||
{
|
||||
DrawChangelogEntry(entry);
|
||||
}
|
||||
|
||||
_hasInitializedCollapsingHeaders = true;
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChangelogEntry(ChangelogEntry entry)
|
||||
{
|
||||
var isCurrent = entry.IsCurrent ?? false;
|
||||
|
||||
var currentColor = isCurrent
|
||||
? UIColors.Get("LightlessGreen")
|
||||
: new Vector4(0.95f, 0.95f, 1.0f, 1.0f);
|
||||
|
||||
var flags = isCurrent ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None;
|
||||
|
||||
if (!_hasInitializedCollapsingHeaders)
|
||||
{
|
||||
ImGui.SetNextItemOpen(isCurrent, ImGuiCond.Always);
|
||||
}
|
||||
|
||||
bool isOpen;
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("ButtonDefault")))
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, currentColor))
|
||||
{
|
||||
isOpen = ImGui.CollapsingHeader($" {entry.Name} — {entry.Date} ", flags);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.95f, 1.0f), $" — {entry.Tagline}");
|
||||
}
|
||||
|
||||
if (!isOpen)
|
||||
return;
|
||||
|
||||
ImGuiHelpers.ScaledDummy(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.Message))
|
||||
{
|
||||
ImGui.TextWrapped(entry.Message);
|
||||
ImGuiHelpers.ScaledDummy(8);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Versions != null)
|
||||
{
|
||||
foreach (var version in entry.Versions)
|
||||
{
|
||||
DrawFeatureSection(version.Number, UIColors.Get("LightlessGreen"));
|
||||
|
||||
foreach (var item in version.Items)
|
||||
{
|
||||
ImGui.BulletText(item);
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawFeatureSection(string title, Vector4 accentColor)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var startPos = ImGui.GetCursorScreenPos();
|
||||
var availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
|
||||
var backgroundMin = startPos + new Vector2(-8, -4);
|
||||
var backgroundMax = startPos + new Vector2(availableWidth + 8, 28);
|
||||
|
||||
var bgColor = new Vector4(0.12f, 0.12f, 0.15f, 0.7f);
|
||||
drawList.AddRectFilled(backgroundMin, backgroundMax, ImGui.GetColorU32(bgColor), 6f);
|
||||
|
||||
drawList.AddRectFilled(
|
||||
backgroundMin,
|
||||
backgroundMin + new Vector2(4, backgroundMax.Y - backgroundMin.Y),
|
||||
ImGui.GetColorU32(accentColor),
|
||||
3f
|
||||
);
|
||||
|
||||
var glowColor = accentColor with { W = 0.15f };
|
||||
drawList.AddRect(
|
||||
backgroundMin,
|
||||
backgroundMax,
|
||||
ImGui.GetColorU32(glowColor),
|
||||
6f,
|
||||
ImDrawFlags.None,
|
||||
1.5f
|
||||
);
|
||||
|
||||
// Calculate vertical centering
|
||||
var textSize = ImGui.CalcTextSize(title);
|
||||
var boxHeight = backgroundMax.Y - backgroundMin.Y;
|
||||
var verticalOffset = (boxHeight - textSize.Y) / 5f;
|
||||
|
||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 8);
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + verticalOffset);
|
||||
ImGui.TextColored(accentColor, title);
|
||||
ImGui.SetCursorPosY(backgroundMax.Y - startPos.Y + ImGui.GetCursorPosY());
|
||||
}
|
||||
|
||||
private void LoadEmbeddedResources()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
// Load changelog
|
||||
using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.changelog.yaml");
|
||||
if (changelogStream != null)
|
||||
{
|
||||
using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128);
|
||||
var yaml = reader.ReadToEnd();
|
||||
_changelog = deserializer.Deserialize<ChangelogFile>(yaml) ?? new();
|
||||
}
|
||||
|
||||
// Load credits
|
||||
using var creditsStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.credits.yaml");
|
||||
if (creditsStream != null)
|
||||
{
|
||||
using var reader = new StreamReader(creditsStream, Encoding.UTF8, true, 128);
|
||||
var yaml = reader.ReadToEnd();
|
||||
_credits = deserializer.Deserialize<CreditsFile>(yaml) ?? new();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore - window will gracefully render with defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,39 @@ namespace LightlessSync.Utils;
|
||||
|
||||
public static class Crypto
|
||||
{
|
||||
//This buffersize seems to be the best sweetpoint for Linux and Windows
|
||||
private const int _bufferSize = 65536;
|
||||
#pragma warning disable SYSLIB0021 // Type or member is obsolete
|
||||
|
||||
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = new();
|
||||
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = [];
|
||||
private static readonly Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
|
||||
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
|
||||
|
||||
public static string GetFileHash(this string filePath)
|
||||
{
|
||||
using SHA1CryptoServiceProvider cryptoProvider = new();
|
||||
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal);
|
||||
using SHA1 sha1 = SHA1.Create();
|
||||
using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static async Task<string> GetFileHashAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
|
||||
var buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0);
|
||||
}
|
||||
|
||||
sha1.TransformFinalBlock([], 0, 0);
|
||||
|
||||
return Convert.ToHexString(sha1.Hash!);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetHash256(this (string, ushort) playerToHash)
|
||||
|
||||
286
LightlessSync/Utils/FileSystemHelper.cs
Normal file
286
LightlessSync/Utils/FileSystemHelper.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.Utils
|
||||
{
|
||||
public static class FileSystemHelper
|
||||
{
|
||||
public enum FilesystemType
|
||||
{
|
||||
Unknown = 0,
|
||||
NTFS, // Compressable on file level
|
||||
Btrfs, // Compressable on file level
|
||||
Ext4, // Uncompressable
|
||||
Xfs, // Uncompressable
|
||||
Apfs, // Compressable on OS
|
||||
HfsPlus, // Compressable on OS
|
||||
Fat, // Uncompressable
|
||||
Exfat, // Uncompressable
|
||||
Zfs // Compressable, not on file level
|
||||
}
|
||||
|
||||
private const string _mountPath = "/proc/mounts";
|
||||
private const int _defaultBlockSize = 4096;
|
||||
private static readonly Dictionary<string, int> _blockSizeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, FilesystemType> _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static FilesystemType GetFilesystemType(string filePath, bool isWine = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
string rootPath;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var dir = info.Directory ?? new DirectoryInfo(filePath);
|
||||
rootPath = dir.Root.FullName;
|
||||
}
|
||||
else
|
||||
{
|
||||
rootPath = GetMountPoint(filePath);
|
||||
if (string.IsNullOrEmpty(rootPath))
|
||||
rootPath = "/";
|
||||
}
|
||||
|
||||
if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType))
|
||||
return cachedType;
|
||||
|
||||
FilesystemType detected;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
|
||||
{
|
||||
var root = new DriveInfo(rootPath);
|
||||
var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
detected = format switch
|
||||
{
|
||||
"NTFS" => FilesystemType.NTFS,
|
||||
"FAT32" => FilesystemType.Fat,
|
||||
"EXFAT" => FilesystemType.Exfat,
|
||||
_ => FilesystemType.Unknown
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
detected = GetLinuxFilesystemType(filePath);
|
||||
}
|
||||
|
||||
if (isWine || IsProbablyWine())
|
||||
{
|
||||
switch (detected)
|
||||
{
|
||||
case FilesystemType.NTFS:
|
||||
case FilesystemType.Unknown:
|
||||
{
|
||||
var linuxDetected = GetLinuxFilesystemType(filePath);
|
||||
if (linuxDetected != FilesystemType.Unknown)
|
||||
{
|
||||
detected = linuxDetected;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_filesystemTypeCache[rootPath] = detected;
|
||||
return detected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMountPoint(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(_mountPath)) return "/";
|
||||
var mounts = File.ReadAllLines(_mountPath);
|
||||
|
||||
string bestMount = "/";
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 3) continue;
|
||||
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
|
||||
string normalizedMount;
|
||||
try { normalizedMount = Path.GetFullPath(mountPoint); }
|
||||
catch { normalizedMount = mountPoint; }
|
||||
|
||||
if (path.StartsWith(normalizedMount, StringComparison.Ordinal) &&
|
||||
normalizedMount.Length > bestMount.Length)
|
||||
{
|
||||
bestMount = normalizedMount;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetMountOptionsForPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var mounts = File.ReadAllLines("/proc/mounts");
|
||||
string bestMount = string.Empty;
|
||||
string mountOptions = string.Empty;
|
||||
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 4) continue;
|
||||
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
string normalized;
|
||||
try { normalized = Path.GetFullPath(mountPoint); }
|
||||
catch { normalized = mountPoint; }
|
||||
|
||||
if (fullPath.StartsWith(normalized, StringComparison.Ordinal) &&
|
||||
normalized.Length > bestMount.Length)
|
||||
{
|
||||
bestMount = normalized;
|
||||
mountOptions = parts[3];
|
||||
}
|
||||
}
|
||||
|
||||
return mountOptions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static FilesystemType GetLinuxFilesystemType(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mountPoint = GetMountPoint(filePath);
|
||||
var mounts = File.ReadAllLines(_mountPath);
|
||||
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 3) continue;
|
||||
var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
if (string.Equals(mount, mountPoint, StringComparison.Ordinal))
|
||||
{
|
||||
var fstype = parts[2].ToLowerInvariant();
|
||||
return fstype switch
|
||||
{
|
||||
"btrfs" => FilesystemType.Btrfs,
|
||||
"ext4" => FilesystemType.Ext4,
|
||||
"xfs" => FilesystemType.Xfs,
|
||||
"zfs" => FilesystemType.Zfs,
|
||||
"apfs" => FilesystemType.Apfs,
|
||||
"hfsplus" => FilesystemType.HfsPlus,
|
||||
_ => FilesystemType.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return _defaultBlockSize;
|
||||
|
||||
var fi = new FileInfo(path);
|
||||
if (!fi.Exists)
|
||||
return _defaultBlockSize;
|
||||
|
||||
var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/";
|
||||
if (_blockSizeCache.TryGetValue(root, out int cached))
|
||||
return cached;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine)
|
||||
{
|
||||
int result = GetDiskFreeSpaceW(root,
|
||||
out uint sectorsPerCluster,
|
||||
out uint bytesPerSector,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
logger?.LogWarning("Failed to determine block size for {root}", root);
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
|
||||
int clusterSize = (int)(sectorsPerCluster * bytesPerSector);
|
||||
_blockSizeCache[root] = clusterSize;
|
||||
logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize);
|
||||
return clusterSize;
|
||||
}
|
||||
|
||||
string realPath = fi.FullName;
|
||||
if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
realPath = "/" + realPath.Substring(3).Replace('\\', '/');
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/bash",
|
||||
Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = "/"
|
||||
};
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
|
||||
string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
string _stderr = proc?.StandardError.ReadToEnd() ?? "";
|
||||
|
||||
try { proc?.WaitForExit(); }
|
||||
catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); }
|
||||
|
||||
if (!(!int.TryParse(stdout, out int block) || block <= 0))
|
||||
{
|
||||
_blockSizeCache[root] = block;
|
||||
logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout);
|
||||
_blockSizeCache[root] = _defaultBlockSize;
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogTrace(ex, "Error determining block size for {path}", path);
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)]
|
||||
private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters);
|
||||
|
||||
//Extra check on
|
||||
public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Dalamud.Interface.Utility;
|
||||
using Lumina.Text;
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString;
|
||||
using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder;
|
||||
using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder;
|
||||
@@ -15,6 +16,9 @@ namespace LightlessSync.Utils;
|
||||
|
||||
public static class SeStringUtils
|
||||
{
|
||||
private static int _seStringHitboxCounter;
|
||||
private static int _iconHitboxCounter;
|
||||
|
||||
public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor)
|
||||
{
|
||||
var b = new DalamudSeStringBuilder();
|
||||
@@ -119,7 +123,7 @@ public static class SeStringUtils
|
||||
|
||||
ImGui.Dummy(new Vector2(0f, textSize.Y));
|
||||
}
|
||||
public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null)
|
||||
public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
@@ -137,12 +141,28 @@ public static class SeStringUtils
|
||||
var textSize = ImGui.CalcTextSize(seString.TextValue);
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
ImGui.InvisibleButton($"##hitbox_{Guid.NewGuid()}", textSize);
|
||||
if (id is not null)
|
||||
{
|
||||
ImGui.PushID(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushID(Interlocked.Increment(ref _seStringHitboxCounter));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ImGui.InvisibleButton("##hitbox", textSize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImGui.PopID();
|
||||
}
|
||||
|
||||
return textSize;
|
||||
}
|
||||
|
||||
public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null)
|
||||
public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
@@ -158,7 +178,23 @@ public static class SeStringUtils
|
||||
var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams);
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
ImGui.InvisibleButton($"##iconHitbox_{Guid.NewGuid()}", drawResult.Size);
|
||||
if (id is not null)
|
||||
{
|
||||
ImGui.PushID(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushID(Interlocked.Increment(ref _iconHitboxCounter));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ImGui.InvisibleButton("##iconHitbox", drawResult.Size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImGui.PopID();
|
||||
}
|
||||
|
||||
return drawResult.Size;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ using LightlessSync.API.Dto.Files;
|
||||
using LightlessSync.API.Routes;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.WebAPI.Files.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
|
||||
namespace LightlessSync.WebAPI.Files;
|
||||
|
||||
@@ -20,17 +26,27 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
private readonly FileCompactor _fileCompactor;
|
||||
private readonly FileCacheManager _fileDbManager;
|
||||
private readonly FileTransferOrchestrator _orchestrator;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
||||
private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30);
|
||||
private volatile bool _disableDirectDownloads;
|
||||
private int _consecutiveDirectDownloadFailures;
|
||||
private bool _lastConfigDirectDownloadsState;
|
||||
|
||||
public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
|
||||
FileTransferOrchestrator orchestrator,
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator)
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor,
|
||||
PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService) : base(logger, mediator)
|
||||
{
|
||||
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
|
||||
_orchestrator = orchestrator;
|
||||
_fileDbManager = fileCacheManager;
|
||||
_fileCompactor = fileCompactor;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_configService = configService;
|
||||
_activeDownloadStreams = new();
|
||||
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
|
||||
|
||||
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
|
||||
{
|
||||
@@ -50,6 +66,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
public bool IsDownloading => CurrentDownloads.Any();
|
||||
|
||||
private bool ShouldUseDirectDownloads()
|
||||
{
|
||||
return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads;
|
||||
}
|
||||
|
||||
public static void MungeBuffer(Span<byte> buffer)
|
||||
{
|
||||
for (int i = 0; i < buffer.Length; ++i)
|
||||
@@ -156,39 +177,67 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup);
|
||||
}
|
||||
|
||||
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
|
||||
|
||||
await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private delegate void DownloadDataCallback(Span<byte> data);
|
||||
|
||||
private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress<long> progress, DownloadDataCallback? callback, CancellationToken ct, bool withToken)
|
||||
{
|
||||
const int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
TimeSpan retryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
HttpResponseMessage response = null!;
|
||||
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl} for request {id}", retryCount + 1, requestUrl, requestId);
|
||||
|
||||
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl);
|
||||
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null)
|
||||
{
|
||||
response?.Dispose();
|
||||
retryCount++;
|
||||
|
||||
Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
|
||||
|
||||
if (retryCount >= maxRetries || ct.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogError($"Max retries reached or cancelled. Failing download for {requestUrl}");
|
||||
Logger.LogError("Max retries reached or cancelled. Failing download for {requestUrl}", requestUrl);
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false); // Wait before retrying
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
response?.Dispose();
|
||||
retryCount++;
|
||||
|
||||
Logger.LogWarning(ex, "Cancellation/timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
|
||||
|
||||
if (retryCount >= maxRetries)
|
||||
{
|
||||
Logger.LogError("Max retries reached for {requestUrl} after TaskCanceledException", requestUrl);
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
response?.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
response?.Dispose();
|
||||
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
|
||||
|
||||
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
|
||||
@@ -196,42 +245,80 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex);
|
||||
}
|
||||
|
||||
throw;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ThrottledStream? stream = null;
|
||||
FileStream? fileStream = null;
|
||||
|
||||
try
|
||||
{
|
||||
fileStream = File.Create(tempPath);
|
||||
fileStream = File.Create(destinationFilename);
|
||||
await using (fileStream.ConfigureAwait(false))
|
||||
{
|
||||
var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
|
||||
var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
|
||||
var buffer = new byte[bufferSize];
|
||||
|
||||
var bytesRead = 0;
|
||||
var limit = _orchestrator.DownloadLimitPerSlot();
|
||||
Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath);
|
||||
Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename);
|
||||
|
||||
stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
|
||||
|
||||
_activeDownloadStreams.TryAdd(stream, 0);
|
||||
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
int bytesRead;
|
||||
try
|
||||
{
|
||||
var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask();
|
||||
while (!readTask.IsCompleted)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false);
|
||||
if (completedTask == readTask)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
MungeBuffer(buffer.AsSpan(0, bytesRead));
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var snapshot = _pairProcessingLimiter.GetSnapshot();
|
||||
if (snapshot.Waiting > 0)
|
||||
{
|
||||
throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})");
|
||||
}
|
||||
|
||||
Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl);
|
||||
}
|
||||
|
||||
bytesRead = await readTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
callback?.Invoke(buffer.AsSpan(0, bytesRead));
|
||||
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
|
||||
|
||||
progress.Report(bytesRead);
|
||||
}
|
||||
|
||||
Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath);
|
||||
Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename);
|
||||
}
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
@@ -240,18 +327,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
try
|
||||
{
|
||||
fileStream?.Close();
|
||||
fileStream?.Close();
|
||||
|
||||
if (!string.IsNullOrEmpty(tempPath) && File.Exists(tempPath))
|
||||
if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
File.Delete(destinationFilename);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
// ignore cleanup errors
|
||||
}
|
||||
throw;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -260,6 +347,134 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
_activeDownloadStreams.TryRemove(stream, out _);
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List<FileReplacementData> fileReplacement, string downloadLabel)
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(downloadStatusKey, out var status))
|
||||
{
|
||||
status.TransferredFiles = 1;
|
||||
status.DownloadStatus = DownloadStatus.Decompressing;
|
||||
}
|
||||
|
||||
FileStream? fileBlockStream = null;
|
||||
try
|
||||
{
|
||||
fileBlockStream = File.OpenRead(blockFilePath);
|
||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||
{
|
||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||
|
||||
try
|
||||
{
|
||||
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
|
||||
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||
|
||||
byte[] compressedFileContent = new byte[fileLengthBytes];
|
||||
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
|
||||
if (readBytes != fileLengthBytes)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
MungeBuffer(compressedFileContent);
|
||||
|
||||
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
PersistFileToStorage(fileHash, filePath);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (fileBlockStream != null)
|
||||
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List<FileReplacementData> fileReplacement,
|
||||
IProgress<long> progress, CancellationToken token, bool slotAlreadyAcquired)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Direct download fallback requested without a direct download URL.");
|
||||
}
|
||||
|
||||
var downloadKey = directDownload.DirectDownloadUrl!;
|
||||
bool slotAcquiredHere = false;
|
||||
string? blockFile = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (!slotAlreadyAcquired)
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(downloadKey, out var tracker))
|
||||
{
|
||||
tracker.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
}
|
||||
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
slotAcquiredHere = true;
|
||||
}
|
||||
|
||||
if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker))
|
||||
{
|
||||
queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
}
|
||||
|
||||
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri),
|
||||
new[] { directDownload.Hash }, token).ConfigureAwait(false);
|
||||
var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"'));
|
||||
|
||||
blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk");
|
||||
|
||||
await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false);
|
||||
|
||||
if (!File.Exists(blockFile))
|
||||
{
|
||||
throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile);
|
||||
}
|
||||
|
||||
await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (slotAcquiredHere)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(blockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(blockFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,30 +522,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
|
||||
{
|
||||
var downloadGroups = CurrentDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal);
|
||||
var objectName = gameObjectHandler?.Name ?? "Unknown";
|
||||
|
||||
foreach (var downloadGroup in downloadGroups)
|
||||
var configAllowsDirect = _configService.Current.EnableDirectDownloads;
|
||||
if (configAllowsDirect != _lastConfigDirectDownloadsState)
|
||||
{
|
||||
_downloadStatus[downloadGroup.Key] = new FileDownloadStatus()
|
||||
_lastConfigDirectDownloadsState = configAllowsDirect;
|
||||
if (configAllowsDirect)
|
||||
{
|
||||
_disableDirectDownloads = false;
|
||||
_consecutiveDirectDownloadFailures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
var allowDirectDownloads = ShouldUseDirectDownloads();
|
||||
|
||||
var directDownloads = new List<DownloadFileTransfer>();
|
||||
var batchDownloads = new List<DownloadFileTransfer>();
|
||||
|
||||
foreach (var download in CurrentDownloads)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads)
|
||||
{
|
||||
directDownloads.Add(download);
|
||||
}
|
||||
else
|
||||
{
|
||||
batchDownloads.Add(download);
|
||||
}
|
||||
}
|
||||
|
||||
var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray();
|
||||
|
||||
foreach (var directDownload in directDownloads)
|
||||
{
|
||||
_downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus()
|
||||
{
|
||||
DownloadStatus = DownloadStatus.Initializing,
|
||||
TotalBytes = downloadGroup.Sum(c => c.Total),
|
||||
TotalBytes = directDownload.Total,
|
||||
TotalFiles = 1,
|
||||
TransferredBytes = 0,
|
||||
TransferredFiles = 0
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var downloadBatch in downloadBatches)
|
||||
{
|
||||
_downloadStatus[downloadBatch.Key] = new FileDownloadStatus()
|
||||
{
|
||||
DownloadStatus = DownloadStatus.Initializing,
|
||||
TotalBytes = downloadBatch.Sum(c => c.Total),
|
||||
TotalFiles = 1,
|
||||
TransferredBytes = 0,
|
||||
TransferredFiles = 0
|
||||
};
|
||||
}
|
||||
|
||||
if (directDownloads.Count > 0 || downloadBatches.Length > 0)
|
||||
{
|
||||
Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length);
|
||||
}
|
||||
|
||||
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
|
||||
|
||||
await Parallel.ForEachAsync(downloadGroups, new ParallelOptions()
|
||||
Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = downloadGroups.Count(),
|
||||
MaxDegreeOfParallelism = downloadBatches.Length,
|
||||
CancellationToken = ct,
|
||||
},
|
||||
async (fileGroup, token) =>
|
||||
{
|
||||
// let server predownload files
|
||||
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
|
||||
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false);
|
||||
Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri,
|
||||
@@ -353,7 +614,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
Progress<long> progress = new((bytesDownloaded) =>
|
||||
var progress = CreateInlineProgress((bytesDownloaded) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -371,7 +632,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler);
|
||||
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -382,72 +643,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
FileStream? fileBlockStream = null;
|
||||
try
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
|
||||
{
|
||||
status.TransferredFiles = 1;
|
||||
status.DownloadStatus = DownloadStatus.Decompressing;
|
||||
}
|
||||
if (!File.Exists(blockFile))
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
fileBlockStream = File.OpenRead(blockFile);
|
||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||
{
|
||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||
|
||||
try
|
||||
{
|
||||
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
|
||||
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath);
|
||||
|
||||
byte[] compressedFileContent = new byte[fileLengthBytes];
|
||||
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
|
||||
if (readBytes != fileLengthBytes)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
MungeBuffer(compressedFileContent);
|
||||
|
||||
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
PersistFileToStorage(fileHash, filePath);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name);
|
||||
await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
if (fileBlockStream != null)
|
||||
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
|
||||
File.Delete(blockFile);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
Logger.LogDebug("Download end: {id}", gameObjectHandler);
|
||||
Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = directDownloads.Count,
|
||||
CancellationToken = ct,
|
||||
},
|
||||
async (directDownload, token) =>
|
||||
{
|
||||
if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker))
|
||||
{
|
||||
Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
var progress = CreateInlineProgress((bytesDownloaded) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value))
|
||||
{
|
||||
value.TransferredBytes += bytesDownloaded;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not set download progress");
|
||||
}
|
||||
});
|
||||
|
||||
if (!ShouldUseDirectDownloads())
|
||||
{
|
||||
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin");
|
||||
var slotAcquired = false;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
slotAcquired = true;
|
||||
|
||||
downloadTracker.DownloadStatus = DownloadStatus.Downloading;
|
||||
Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
|
||||
await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false);
|
||||
|
||||
Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0);
|
||||
|
||||
downloadTracker.DownloadStatus = DownloadStatus.Decompressing;
|
||||
|
||||
try
|
||||
{
|
||||
var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase));
|
||||
if (replacement == null || replacement.GamePaths.Length == 0)
|
||||
{
|
||||
Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileExtension = replacement.GamePaths[0].Split(".")[^1];
|
||||
var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension);
|
||||
Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename);
|
||||
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false);
|
||||
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
|
||||
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false);
|
||||
PersistFileToStorage(directDownload.Hash, finalFilename);
|
||||
|
||||
downloadTracker.TransferredFiles = 1;
|
||||
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash);
|
||||
Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash);
|
||||
ClearDownload();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var expectedDirectDownloadFailure = ex is InvalidDataException;
|
||||
var failureCount = 0;
|
||||
|
||||
if (expectedDirectDownloadFailure)
|
||||
{
|
||||
Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash);
|
||||
}
|
||||
else
|
||||
{
|
||||
failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures);
|
||||
Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false);
|
||||
|
||||
if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads)
|
||||
{
|
||||
_disableDirectDownloads = true;
|
||||
Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount);
|
||||
}
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
{
|
||||
if (slotAcquired)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
slotAcquired = false;
|
||||
}
|
||||
|
||||
Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash);
|
||||
ClearDownload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (slotAcquired)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(tempFilename);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false);
|
||||
|
||||
Logger.LogDebug("Download end: {id}", objectName);
|
||||
|
||||
ClearDownload();
|
||||
}
|
||||
@@ -554,4 +910,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
_orchestrator.ClearDownloadRequest(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IProgress<long> CreateInlineProgress(Action<long> callback)
|
||||
{
|
||||
return new InlineProgress(callback);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress : IProgress<long>
|
||||
{
|
||||
private readonly Action<long> _callback;
|
||||
|
||||
public InlineProgress(Action<long> callback)
|
||||
{
|
||||
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
}
|
||||
|
||||
public void Report(long value)
|
||||
{
|
||||
_callback(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption, withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct,
|
||||
bool withToken = true) where T : class
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
if (content is not ByteArrayContent)
|
||||
requestMessage.Content = JsonContent.Create(content);
|
||||
else
|
||||
requestMessage.Content = content as ByteArrayContent;
|
||||
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct)
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content,
|
||||
CancellationToken ct, bool withToken = true)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
requestMessage.Content = content;
|
||||
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WaitForDownloadSlotAsync(CancellationToken token)
|
||||
@@ -144,10 +147,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true)
|
||||
{
|
||||
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
if (withToken)
|
||||
{
|
||||
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ public class DownloadFileTransfer : FileTransfer
|
||||
}
|
||||
get => Dto.Size;
|
||||
}
|
||||
public string? DirectDownloadUrl => ((DownloadFileDto)TransferDto).CDNDownloadUrl;
|
||||
|
||||
public long TotalRaw => Dto.RawSize;
|
||||
private DownloadFileDto Dto => (DownloadFileDto)TransferDto;
|
||||
|
||||
@@ -84,7 +84,7 @@ public partial class ApiController
|
||||
|
||||
public async Task<UserProfileDto> UserGetProfile(UserDto dto)
|
||||
{
|
||||
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null);
|
||||
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null, BannerPictureBase64: null, Tags: null);
|
||||
return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,17 +107,17 @@ public partial class ApiController
|
||||
}
|
||||
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
Logger.LogDebug("Client_ReceiveBroadcastPairRequest: {dto}", dto);
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty);
|
||||
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName;
|
||||
|
||||
_lightlessNotificationService.ShowPairRequestNotification(
|
||||
senderName,
|
||||
request.HashedCid,
|
||||
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName),
|
||||
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid));
|
||||
ExecuteSafely(() =>
|
||||
{
|
||||
Mediator.Publish(new PairRequestReceivedMessage(dto.myHashedCid, dto.message ?? string.Empty));
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -195,7 +195,14 @@ public partial class ApiController
|
||||
public Task Client_UserUpdateProfile(UserDto dto)
|
||||
{
|
||||
Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto);
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileDataMessage(dto.User)));
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileUserDataMessage(dto.User)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
|
||||
{
|
||||
Logger.LogDebug("Client_GroupSendProfile: {dto}", groupInfo);
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileGroupDataMessage(groupInfo.Group)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -380,6 +387,12 @@ public partial class ApiController
|
||||
_lightlessHub!.On(nameof(Client_UserUpdateProfile), act);
|
||||
}
|
||||
|
||||
public void ClientGroupSendProfile(Action<GroupProfileDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
_lightlessHub!.On(nameof(Client_GroupSendProfile), act);
|
||||
}
|
||||
|
||||
public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
@@ -115,6 +115,18 @@ public partial class ApiController
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false);
|
||||
}
|
||||
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
if (!IsConnected) return new GroupProfileDto(Group: dto.Group, Description: null, Tags: null, PictureBase64: null, IsNsfw: false, BannerBase64: null, IsDisabled: false);
|
||||
return await _lightlessHub!.InvokeAsync<GroupProfileDto>(nameof(GroupGetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task GroupSetProfile(GroupProfileDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
await _lightlessHub!.InvokeAsync(nameof(GroupSetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
|
||||
{
|
||||
@@ -139,7 +151,6 @@ public partial class ApiController
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
private void CheckConnection()
|
||||
{
|
||||
if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected");
|
||||
|
||||
@@ -2,6 +2,7 @@ using Dalamud.Utility;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.API.SignalR;
|
||||
@@ -32,7 +33,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly NotificationService _lightlessNotificationService;
|
||||
private CancellationTokenSource _connectionCancellationTokenSource;
|
||||
private ConnectionDto? _connectionDto;
|
||||
private bool _doNotNotifyOnNextInfo = false;
|
||||
@@ -54,7 +54,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
_serverManager = serverManager;
|
||||
_tokenProvider = tokenProvider;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_lightlessNotificationService = lightlessNotificationService;
|
||||
_connectionCancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
|
||||
@@ -608,17 +607,42 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
ServerState = state;
|
||||
}
|
||||
|
||||
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
|
||||
public Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
|
||||
public Task UpdateChatPresence(ChatPresenceUpdateDto presence)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task Client_ChatReceive(ChatMessageDto message)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task GroupSetProfile(GroupProfileDto dto)
|
||||
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SendChatMessage(ChatSendRequestDto request)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task ReportChatMessage(ChatReportSubmitDto request)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ChatParticipantResolveResultDto?> ResolveChatParticipant(ChatParticipantResolveRequestDto request)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -70,13 +70,6 @@ public class HubFactory : MediatorSubscriberBase
|
||||
_ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling
|
||||
};
|
||||
|
||||
if (_isWine && !_serverConfigurationManager.CurrentServer.ForceWebSockets
|
||||
&& transportType.HasFlag(HttpTransportType.WebSockets))
|
||||
{
|
||||
Logger.LogDebug("Wine detected, falling back to ServerSentEvents / LongPolling");
|
||||
transportType = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Building new HubConnection using transport {transport}", transportType);
|
||||
|
||||
_instance = new HubConnectionBuilder()
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
"Microsoft.IdentityModel.Tokens": "8.7.0"
|
||||
}
|
||||
},
|
||||
"YamlDotNet": {
|
||||
"type": "Direct",
|
||||
"requested": "[16.3.0, )",
|
||||
"resolved": "16.3.0",
|
||||
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
|
||||
},
|
||||
"K4os.Compression.LZ4": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.3.8",
|
||||
|
||||
Submodule PenumbraAPI updated: 648b6fc2ce...a2f8923546
Reference in New Issue
Block a user