Compare commits

...

147 Commits

Author SHA1 Message Date
defnotken
9c794137c1 changelog added 2025-11-11 12:43:09 -06:00
defnotken
4a256f7807 update penumbra api 2025-11-11 12:03:18 -06:00
defnotken
25756561b9 updating lightlessapi 2025-11-11 12:02:37 -06:00
defnotken
e8c546c128 update lightless API pt 1 2025-11-11 11:54:21 -06:00
d4ba1cf437 Merge pull request 'Turned off btrfs compression for now as not completed' (#85) from linux-improvements into 1.12.4
Reviewed-on: #85
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-11 18:41:05 +01:00
e0d1f98c70 Merge branch '1.12.4' into linux-improvements 2025-11-11 17:12:51 +01:00
cake
1862689b1b Changed some commands in file getting, redone compression check commands and turned off btrfs compactor for 1.12.4 2025-11-11 17:09:50 +01:00
325dc8947d Merge pull request 'download notification stuck fix, more x and y offset positions' (#84) from notification-reworks into 1.12.4
Reviewed-on: #84
2025-11-10 22:00:58 +01:00
choco
95e7f2daa7 download notification progress and download bar, chat only option for notifications (idk why you would bother even enabling the lightless nofis then) 2025-11-10 21:59:20 +01:00
choco
41a303dc91 auto dismiss notifs if no updates 2025-11-10 21:29:55 +01:00
choco
25b03aea15 download dismiss message if downloads are complete 2025-11-10 21:22:28 +01:00
choco
b6564156f0 Merge remote-tracking branch 'origin/notification-reworks' into notification-reworks 2025-11-10 21:05:48 +01:00
choco
f89ce900c7 more offset changes accepting minus for notifications till -2500 2025-11-10 21:05:39 +01:00
299abc21ee Merge branch '1.12.4' into notification-reworks 2025-11-10 21:00:01 +01:00
choco
c02a8ed2ee notification clickthrougable, update notes centered in the middle of the screen unmovable 2025-11-10 20:57:43 +01:00
choco
8692e877cf download notification stuck fix, more x and y offset positions 2025-11-10 10:59:42 +01:00
cake
7de72471bb Refactored 2025-11-10 06:25:35 +01:00
cake
d7182e9d57 Hopefully fixes all issues with linux based path finding 2025-11-10 03:52:37 +01:00
2b02de731a Merge pull request 'Some changes on the file compression for linux and windows regards threading.' (#83) from linux-improvements into 1.12.4
Reviewed-on: #83
2025-11-09 06:11:34 +01:00
cake
e9082ab8d0 forget semicolomn.. 2025-11-07 06:07:34 +01:00
2a06a11cbc Merge branch '1.12.4' into linux-improvements 2025-11-07 05:29:34 +01:00
cake
557121a9b7 Added batching for the File Frag command for the iscompressed calls. 2025-11-07 05:27:58 +01:00
b22140a8d4 Merge pull request 'Added more checks in nameplate handler' (#82) from more-checks-nameplate into 1.12.4
Reviewed-on: #82
2025-11-06 18:14:45 +01:00
4db468a480 Merge pull request 'Btrfs Compactor work, defaulted linux on websockets.' (#77) from linux-improvements into 1.12.4
Reviewed-on: #77
2025-11-06 18:13:52 +01:00
8d8f8d20cd Merge pull request 'Syncshell grouped folders pausing function' (#76) from grouped-folder-pause into 1.12.4
Reviewed-on: #76
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:41 +01:00
3722b79615 Merge pull request 'Fixed amount of lightfinder users, added user names in the server info list for lightfinder. Added /lightless command' (#75) from lightfinder-dtr-changes into 1.12.4
Reviewed-on: #75
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:33 +01:00
cf97e7e800 Merge pull request 'pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on' (#74) from direct-pair-button into 1.12.4
Reviewed-on: #74
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-06 18:13:22 +01:00
azyges
1d672d2552 improve checks and add logging 2025-11-06 23:40:58 +09:00
cake
35636f27f6 Cleanup 2025-11-03 21:47:15 +01:00
cake
1b686e45dc Added more null checks and redid active broadcasting cache. 2025-11-03 20:19:02 +01:00
cake
b6aa2bebb1 Added more checks. 2025-11-03 19:59:12 +01:00
cake
cfc9f60176 Added safe checks on enqueue. 2025-11-03 19:27:47 +01:00
cake
d4dca455ba Clean-up, added extra checks on linux in cache monitor, documentation added 2025-11-03 18:54:35 +01:00
76c2777f00 Merge pull request 'Changed warning text for Brio #78' (#81) from character-hub-brio-change into 1.12.4
Reviewed-on: #81
2025-11-01 00:12:49 +01:00
cake
0af2a6134b Changed warning text for Brio 2025-11-01 00:09:54 +01:00
cake
6e3c60f627 Changes in file compression for windows, redone linux side because wine issues. 2025-10-31 23:47:41 +01:00
cake
5feb74c1c0 Added another wine check in parralel with dalamud 2025-10-30 03:46:55 +01:00
cake
c1770528f3 Added wine checks, path fixing on wine -> linux 2025-10-30 03:34:56 +01:00
cake
bf139c128b Added fail safes in compact of WOF incase 2025-10-30 03:11:38 +01:00
cake
b3cc41382f Refactored a bit, added comments on the file systems. 2025-10-30 03:05:53 +01:00
cake
7c4d0fd5e9 Added comments, clean-up 2025-10-29 22:54:50 +01:00
cake
c37e3badf1 Check if wine is used. 2025-10-29 06:09:44 +01:00
f4478f653a Merge branch '1.12.4' into linux-improvements 2025-10-29 04:53:36 +01:00
cake
3f85852618 Added string comparisons 2025-10-29 04:52:17 +01:00
cake
3e626c5e47 Cleanup some code, removed ntfs usage on cache monitor 2025-10-29 04:49:46 +01:00
cake
9a846a37d4 Redone handling of windows compactor handling. 2025-10-29 04:43:18 +01:00
cake
177534d78b Implemented compactor to work on BTRFS, redid cache a bit for better function on linux. Removed error for websockets, it will be forced on wine again. 2025-10-29 04:37:24 +01:00
cake
de75b90703 Added spacing on function 2025-10-28 18:23:01 +01:00
cake
c16891021c Added pause button for all syncshells and grouped syncshells. 2025-10-28 18:20:57 +01:00
d19d1c0a3a Merge branch '1.12.4' into lightfinder-dtr-changes 2025-10-28 15:47:42 +01:00
choco
cabc4ec0fe removed lightfinder on check 2025-10-28 00:57:49 +01:00
cake
8bccdc5ef1 Added lightless command. 2025-10-27 22:26:03 +01:00
cake
ce5f8a43a2 Added list of users names in the dtr entry whenever lightfinder is active 2025-10-27 16:16:40 +01:00
choco
437731749f pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on 2025-10-26 17:34:17 +01:00
defnotken
55e78e088a Initialize .4 2025-10-24 09:45:24 -05:00
5abc297a94 1.12.3 - LightSpeed
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 51s
Reviewed-on: #60
Reviewed-by: celine <celine@noreply.git.lightless-sync.org>
2025-10-24 16:22:21 +02:00
choco
8aad714918 removed wrong ondisconnect notification 2025-10-23 00:40:54 +02:00
b5bdededae Merge pull request 'Client API changes' (#72) from banner-api-changes into 1.12.3
Reviewed-on: #72
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 22:51:29 +02:00
CakeAndBanana
487156e4f9 Submodule update 2025-10-21 22:48:26 +02:00
90d8f691d2 Merge branch '1.12.3' into banner-api-changes 2025-10-21 22:41:29 +02:00
CakeAndBanana
764bb8bae2 API changes 2025-10-21 22:38:12 +02:00
5d2c58bf3e Merge pull request 'some caching stuff and bug fixes' (#71) from some-garbage-optimization into 1.12.3
Reviewed-on: #71
2025-10-21 20:46:15 +02:00
azyges
6bb00c50d8 improve logging fallback 2025-10-22 03:33:51 +09:00
azyges
1a89c2caee some caching stuff and bug fixes 2025-10-22 03:20:13 +09:00
defnotken
1e97f27cb8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-21 11:22:31 -05:00
defnotken
7aadbcec10 Wording changes 2025-10-21 11:22:19 -05:00
choco
a32ac02c6d download notification stuck fix 2025-10-21 09:59:30 +02:00
f48698373b Merge pull request 'notification-cleanup' (#70) from notification-cleanup into 1.12.3
Reviewed-on: #70
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-20 21:28:15 +02:00
ee20b6fa5f version 1.12.3 2025-10-20 21:25:28 +02:00
f11741225b Merge branch 'dev' into notification-cleanup 2025-10-20 21:16:47 +02:00
choco
147baa4c1b api cleanup, decline message on notification decline 2025-10-20 21:16:30 +02:00
choco
4f5ef8ff4b type cleanup 2025-10-20 14:51:10 +02:00
choco
fae6d31792 Merge remote-tracking branch 'origin/notification-cleanup' into notification-cleanup 2025-10-20 14:32:29 +02:00
choco
b4dd0ee0e1 type cleanup 2025-10-20 14:32:21 +02:00
f5458c7f97 Delete CONTRIBUTING.md 2025-10-20 14:05:51 +02:00
923f118a47 Delete DEVELOPMENT.md 2025-10-20 14:05:45 +02:00
choco
0cb71e5444 service cleanups, containing logic directly now 2025-10-20 14:00:54 +02:00
defnotken
268fd471fe welcome screen fix bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 34s
2025-10-19 15:50:02 -05:00
defnotken
d517a21f5d Merge branch '1.12.3' into dev 2025-10-19 15:49:16 -05:00
cac94374d9 Merge pull request 'patch-notes' (#69) from patch-notes into 1.12.3
Reviewed-on: #69
2025-10-19 22:48:44 +02:00
choco
b513e0555b temp menu removal 2025-10-19 22:46:36 +02:00
choco
de8c9cf035 Merge remote-tracking branch 'origin/patch-notes' into patch-notes 2025-10-19 22:43:58 +02:00
choco
7f8872cbe0 added open changelog button to settings title menu 2025-10-19 22:43:45 +02:00
defnotken
5626a34755 Merge branch '1.12.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s
2025-10-19 15:16:02 -05:00
defnotken
a98afdda01 Updating changelog. 2025-10-19 15:15:30 -05:00
4373092d44 Merge pull request 'patch-notes' (#66) from patch-notes into 1.12.3
Reviewed-on: #66
2025-10-19 22:01:15 +02:00
7b5c61371e Merge branch '1.12.3' into patch-notes 2025-10-19 22:00:58 +02:00
9bd997f699 Merge pull request 'Added profile editor on syncshell admin.' (#68) from syncshell-profiles into 1.12.3
Reviewed-on: #68
2025-10-19 22:00:51 +02:00
e2511a5c1f Merge branch '1.12.3' into syncshell-profiles 2025-10-19 22:00:16 +02:00
CakeAndBanana
e8760a8937 Fixed nsfwf 2025-10-19 21:59:04 +02:00
CakeAndBanana
7d4e097be8 Added nsfw on syncshell profile. 2025-10-19 21:58:12 +02:00
CakeAndBanana
2cba1ccfe0 reverted join, added apicontroller stuff 2025-10-19 21:56:03 +02:00
06921e1dd1 Merge branch '1.12.3' into patch-notes 2025-10-19 21:49:18 +02:00
defnotken
68ba5f4b06 dev build
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 27s
2025-10-19 14:47:30 -05:00
defnotken
217c160ec7 update submodule 2025-10-19 14:43:40 -05:00
defnotken
ff010fa1f8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 14:43:01 -05:00
f7145339b3 Merge pull request 'init' (#67) from lightspeed into 1.12.3
Reviewed-on: #67
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-19 21:41:16 +02:00
azyges
aa2b828386 init 2025-10-20 04:20:11 +09:00
CakeAndBanana
547db3a76b Added tag calls for new api changes 2025-10-19 21:10:07 +02:00
defnotken
4ac8b24524 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 12:33:40 -05:00
CakeAndBanana
d72cc207e1 Made tags an array of integers instead of strings 2025-10-19 18:53:31 +02:00
CakeAndBanana
477f5aa6e7 Fixed some stuff 2025-10-19 18:41:02 +02:00
CakeAndBanana
edb7232b17 Added nsfw in group profile editor. 2025-10-19 16:55:42 +02:00
choco
44177ab7bd broadcast bypass toggle gone 2025-10-17 20:07:14 +02:00
choco
47b7ecd521 forced current changelog version to be opened on default 2025-10-17 15:36:50 +02:00
choco
8a3902ec2b init change :) 2025-10-16 23:44:15 +02:00
choco
ea8f8e3895 last null check removal 2025-10-16 23:34:10 +02:00
choco
f1af6601cc removed null check 2025-10-16 23:30:06 +02:00
choco
2d094404df proper version checker on plugin laoding 2025-10-16 23:21:14 +02:00
choco
8fdff1eb18 SHOWING changelog everytime till the got it button is pressed, should reappear on version updates according to the current settings 2025-10-16 23:03:32 +02:00
choco
9170b5205c removed temp changelog on loading 2025-10-16 22:54:56 +02:00
choco
dccd2cdc36 changelog cleanup, credits tab 2025-10-16 22:52:46 +02:00
choco
6d01d47c2f broadcast notifications, action button for reconnecting once expired 2025-10-16 22:13:40 +02:00
CakeAndBanana
280c80d89f Changed the layout a bit, added nsfw 2025-10-16 15:19:01 +02:00
choco
92b8d4a1cd credits integration into the yaml and ui 2025-10-16 14:35:30 +02:00
choco
f77d109d00 added changelog model and update notes UI with particle effects 2025-10-16 10:37:00 +02:00
choco
823dd39a9b improved particle animations, took some inspiration from brio and character + kinda pogged at their change log 2025-10-16 01:29:38 +02:00
choco
d5c12e81c3 banner with particles 2025-10-15 22:59:08 +02:00
choco
04c00af92e check if completed intro setup process 2025-10-15 16:37:56 +02:00
choco
a66a43dda8 patch notes setup, added some of the older patchnotes into a changelog.yaml 2025-10-15 13:29:32 +02:00
77ff8ae372 Merge pull request 'notification-changes' (#65) from notification-changes into 1.12.3
Reviewed-on: #65
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-14 22:43:10 +02:00
CakeAndBanana
011cf7951b Added more documentation, fixed some small issues with cache 2025-10-14 20:45:05 +02:00
defnotken
7d480b9e2c Defensive handling and NRE removal. 2025-10-14 10:33:44 -05:00
choco
cf27a67296 optional action button toggle for pair request (default on) 2025-10-14 15:07:46 +02:00
choco
d6a4595bb8 old debug line removals, better alignment performance notifs 2025-10-14 14:54:30 +02:00
choco
f202818b55 performance notifcation addition, with some regular bugfixes regarding the flexing of the notifications 2025-10-14 11:46:14 +02:00
choco
3f2e4d6640 small service cleanup 2025-10-14 09:36:10 +02:00
choco
90b483e4ea Merge remote-tracking branch 'origin/1.12.3' into notification-changes 2025-10-13 22:24:50 +02:00
434c7d5f4a Merge pull request 'add stuff' (#63) from syncshell-profiles-2 into syncshell-profiles
Reviewed-on: #63
2025-10-13 06:12:24 +02:00
defnotken
a4eb840589 add stuff 2025-10-12 22:59:50 -05:00
CakeAndBanana
e80806ef9d Changes some calls 2025-10-13 01:09:05 +02:00
CakeAndBanana
c447c33b7a Fixed callbacks for cleaning up profiles. 2025-10-13 00:39:30 +02:00
choco
bcb524df52 text auto flexing on dismiss removed 2025-10-13 00:13:21 +02:00
b64bb66119 Merge pull request 'notification-changes' (#62) from notification-changes into 1.12.3
Reviewed-on: #62
2025-10-12 23:33:42 +02:00
choco
118edb9dea notif overlay flex with 1 sec delay removal 2025-10-12 23:11:18 +02:00
CakeAndBanana
b43ceb9f7e Changed syncshell profiles a bit. 2025-10-12 22:49:50 +02:00
choco
6c0d00dc39 update intervall prevent spam better performance 2025-10-12 21:07:32 +02:00
CakeAndBanana
be847c16b8 Added profile settings for Syncshells. 2025-10-12 20:04:09 +02:00
02a680f8cc Merge pull request 'Merge master to profiles' (#61) from master into syncshell-profiles
Reviewed-on: #61
2025-10-12 19:19:37 +02:00
defnotken
dae8127ac8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-12 12:11:18 -05:00
defnotken
0635caab65 Safety checks for NullDrawObject 2025-10-12 12:09:06 -05:00
1530ac3911 Merge pull request 'notification-changes' (#59) from notification-changes into 1.12.3
Reviewed-on: #59
2025-10-12 18:08:52 +02:00
3f80467180 Merge branch '1.12.3' into notification-changes 2025-10-12 18:08:43 +02:00
defnotken
4b4e587a89 1.12.3 initial 2025-10-12 11:07:24 -05:00
choco
02c3846031 column rename for better UX c: 2025-10-12 18:02:53 +02:00
choco
a8a01b3034 added more customization to the notifs, settings improvemnts, left and right notifs, animations for sliding in and out 2025-10-12 18:00:49 +02:00
CakeAndBanana
98c3a2c7f8 Added syncshell profile related items. 2025-10-10 06:42:59 +02:00
66 changed files with 5596 additions and 1152 deletions

View 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."

View 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"

View File

@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public bool StorageisNTFS { get; private set; } = false; public bool StorageisNTFS { get; private set; } = false;
public bool StorageIsBtrfs { get ; private set; } = false;
public void StartLightlessWatcher(string? lightlessPath) public void StartLightlessWatcher(string? lightlessPath)
{ {
LightlessWatcher?.Dispose(); 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."); Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
return; return;
} }
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); if (fsType == FileSystemHelper.FilesystemType.NTFS)
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase); {
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS); 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); Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
LightlessWatcher = new() LightlessWatcher = new()
@@ -392,51 +403,94 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void RecalculateFileCacheSize(CancellationToken token) 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; FileCacheSize = 0;
return; return;
} }
FileCacheSize = -1; FileCacheSize = -1;
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); bool isWine = _dalamudUtil?.IsWine ?? false;
try 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) 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)) var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.OrderBy(f => f.LastAccessTime).ToList(); .Select(f => new FileInfo(f))
FileCacheSize = files .OrderBy(f => f.LastAccessTime)
.Sum(f => .ToList();
{
token.ThrowIfCancellationRequested();
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); var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes)
if (FileCacheSize < maxCacheInBytes) return; return;
var maxCacheBuffer = maxCacheInBytes * 0.05d; var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
{ {
var oldestFile = files[0]; var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName); try
files.Remove(oldestFile); {
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; if (ct.IsCancellationRequested) return;
// scan new files var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
if (allScannedFiles.Any(c => !c.Value)) foreach (var cachePath in newFiles)
{ {
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key), if (ct.IsCancellationRequested) break;
new ParallelOptions() ProcessOne(cachePath);
{ Interlocked.Increment(ref _currentFileProgress);
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) return; Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
if (!_ipcManager.Penumbra.APIAvailable) void ProcessOne(string? cachePath)
{ {
Logger.LogWarning("Penumbra not available"); if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
return; {
} Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
try if (!_ipcManager.Penumbra.APIAvailable)
{ {
var entry = _fileDbManager.CreateFileEntry(cachePath); Logger.LogWarning("Penumbra not available");
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath); return;
} }
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
Interlocked.Increment(ref _currentFileProgress); try
}); {
var entry = _fileDbManager.CreateFileEntry(cachePath);
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value)); 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"); Logger.LogDebug("Scan complete");

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly Lock _fileWriteLock = new(); private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger; private readonly ILogger<FileCacheManager> _logger;
private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder; public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
@@ -202,42 +203,72 @@ public sealed class FileCacheManager : IHostedService
return output; 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))); _lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage"); _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 = []; var cacheEntries = _fileCaches.Values
int i = 0; .SelectMany(v => v.Values)
foreach (var fileCache in cacheEntries) .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 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)) 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); 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); brokenEntities.Add(fileCache);
} }
} }).ConfigureAwait(false);
foreach (var brokenEntity in brokenEntities) foreach (var brokenEntity in brokenEntities)
{ {
@@ -249,12 +280,14 @@ public sealed class FileCacheManager : IHostedService
} }
catch (Exception ex) 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))); _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) public string GetCacheFilePath(string hash, string extension)
@@ -462,6 +495,7 @@ public sealed class FileCacheManager : IHostedService
string[] existingLines = File.ReadAllLines(_csvPath); string[] existingLines = File.ReadAllLines(_csvPath);
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion) if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
{ {
_csvHeaderEnsured = true;
return; return;
} }
@@ -481,6 +515,18 @@ public sealed class FileCacheManager : IHostedService
} }
File.WriteAllText(_csvPath, rebuilt.ToString()); File.WriteAllText(_csvPath, rebuilt.ToString());
_csvHeaderEnsured = true;
}
private void EnsureCsvHeaderLockedCached()
{
if (_csvHeaderEnsured)
{
return;
}
EnsureCsvHeaderLocked();
_csvHeaderEnsured = true;
} }
private void BackupUnsupportedCache(string suffix) private void BackupUnsupportedCache(string suffix)
@@ -540,10 +586,11 @@ public sealed class FileCacheManager : IHostedService
if (!File.Exists(_csvPath)) if (!File.Exists(_csvPath))
{ {
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
_csvHeaderEnsured = true;
} }
else else
{ {
EnsureCsvHeaderLocked(); EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowUploading { get; set; } = true; public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30; public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12; public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true; public bool TransferBarsShowText { get; set; } = true;
@@ -86,6 +87,7 @@ public class LightlessConfig : ILightlessConfiguration
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi; public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi; public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay; public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
// Basic Settings // Basic Settings
public float NotificationOpacity { get; set; } = 0.95f; public float NotificationOpacity { get; set; } = 0.95f;
@@ -95,6 +97,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowNotificationTimestamp { get; set; } = false; public bool ShowNotificationTimestamp { get; set; } = false;
// Position & Layout // Position & Layout
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
public int NotificationOffsetY { get; set; } = 50; public int NotificationOffsetY { get; set; } = 50;
public int NotificationOffsetX { get; set; } = 0; public int NotificationOffsetX { get; set; } = 0;
public float NotificationWidth { get; set; } = 350f; public float NotificationWidth { get; set; } = 350f;
@@ -102,6 +105,7 @@ public class LightlessConfig : ILightlessConfiguration
// Animation & Effects // Animation & Effects
public float NotificationAnimationSpeed { get; set; } = 10f; public float NotificationAnimationSpeed { get; set; } = 10f;
public float NotificationSlideSpeed { get; set; } = 10f;
public float NotificationAccentBarWidth { get; set; } = 3f; public float NotificationAccentBarWidth { get; set; } = 3f;
// Duration per Type // Duration per Type
@@ -109,17 +113,20 @@ public class LightlessConfig : ILightlessConfiguration
public int WarningNotificationDurationSeconds { get; set; } = 15; public int WarningNotificationDurationSeconds { get; set; } = 15;
public int ErrorNotificationDurationSeconds { get; set; } = 20; public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180; 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 CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15 public uint CustomWarningSoundId { get; set; } = 16; // Se15
public uint CustomErrorSoundId { get; set; } = 16; // Se15 public uint CustomErrorSoundId { get; set; } = 16; // Se15
public uint PairRequestSoundId { get; set; } = 5; // Se5 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 DisableInfoSound { get; set; } = true;
public bool DisableWarningSound { get; set; } = true; public bool DisableWarningSound { get; set; } = true;
public bool DisableErrorSound { get; set; } = true; public bool DisableErrorSound { get; set; } = true;
public bool DisablePairRequestSound { 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 UseFocusTarget { get; set; } = false;
public bool overrideFriendColor { get; set; } = false; public bool overrideFriendColor { get; set; } = false;
public bool overridePartyColor { 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 DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false; public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null; public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
} }

View File

@@ -17,5 +17,12 @@ public enum NotificationType
Warning, Warning,
Error, Error,
PairRequest, PairRequest,
Download Download,
Performance
}
public enum NotificationCorner
{
Right,
Left
} }

View File

@@ -13,5 +13,4 @@ public class ServerStorage
public bool UseOAuth2 { get; set; } = false; public bool UseOAuth2 { get; set; } = false;
public string? OAuthToken { get; set; } = null; public string? OAuthToken { get; set; } = null;
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets; public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
public bool ForceWebSockets { get; set; } = false;
} }

View File

@@ -9,6 +9,7 @@ using LightlessSync.UI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog;
using System.Reflection; using System.Reflection;
namespace LightlessSync; namespace LightlessSync;
@@ -116,6 +117,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
return Task.CompletedTask; 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() private void DalamudUtilOnLogIn()
{ {
Logger?.LogDebug("Client login"); Logger?.LogDebug("Client login");
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
CheckVersion();
#if !DEBUG #if !DEBUG
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information) if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)

View File

@@ -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"> <Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>1.12.2</Version> <Version>1.12.4</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
@@ -46,6 +46,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -64,6 +65,8 @@
<None Update="images\icon.png"> <None Update="images\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
<EmbeddedResource Include="Localization\de.json" /> <EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" /> <EmbeddedResource Include="Localization\fr.json" />
</ItemGroup> </ItemGroup>

View File

@@ -1,4 +1,6 @@
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,21 +12,38 @@ public class FileDownloadManagerFactory
private readonly FileCacheManager _fileCacheManager; private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor; private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator; private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly LightlessConfigService _configService;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator, public FileDownloadManagerFactory(
FileCacheManager fileCacheManager, FileCompactor fileCompactor) ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter,
LightlessConfigService configService)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator; _fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
} }
public FileDownloadManager Create() 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);
} }
} }

View File

@@ -98,7 +98,19 @@ public class PlayerDataFactory
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) 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) private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)

View File

@@ -138,7 +138,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{ {
if (_allClientPairs.TryGetValue(user, out var pair)) if (_allClientPairs.TryGetValue(user, out var pair))
{ {
Mediator.Publish(new ClearProfileDataMessage(pair.UserData)); Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData));
pair.MarkOffline(); pair.MarkOffline();
} }
@@ -149,7 +149,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{ {
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto); 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]; var pair = _allClientPairs[dto.User];
if (pair.HasCachedPlayer) if (pair.HasCachedPlayer)
@@ -254,7 +254,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused()) 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; pair.UserPair.OtherPermissions = dto.Permissions;
@@ -280,7 +280,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused()) 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; pair.UserPair.OwnPermissions = dto.Permissions;

View File

@@ -246,6 +246,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>(); collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>(); collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>(); collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(), collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(), 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, 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<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<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUI>((s) => collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>((s) =>
new LightlessNotificationUI( new LightlessNotificationUi(
s.GetRequiredService<ILogger<LightlessNotificationUI>>(), s.GetRequiredService<ILogger<LightlessNotificationUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>())); s.GetRequiredService<LightlessConfigService>()));
@@ -268,8 +269,7 @@ public sealed class Plugin : IDalamudPlugin
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(), s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
s.GetRequiredService<UiFactory>(), s.GetRequiredService<UiFactory>(),
s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessMediator>()));
s.GetRequiredService<NotificationService>()));
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(), collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>())); s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));

View File

@@ -28,7 +28,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
private readonly CancellationTokenSource _cleanupCts = new(); private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask; private Task? _cleanupTask;
private int _checkEveryFrames = 20; private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0; private int _frameCounter = 0;
private int _lookupsThisFrame = 0; private int _lookupsThisFrame = 0;
private const int MaxLookupsPerFrame = 30; private const int MaxLookupsPerFrame = 30;
@@ -221,6 +221,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid))); (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) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); base.Dispose(disposing);

View File

@@ -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.API.Dto.User;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -140,6 +144,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
IsLightFinderAvailable = false; IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true); ApplyBroadcastDisabled(forcePublish: true);
_logger.LogDebug("Cleared Lightfinder state due to disconnect."); _logger.LogDebug("Cleared Lightfinder state due to disconnect.");
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
@@ -236,6 +241,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{ {
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect."); _logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true)); _mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
_mediator.Publish(new NotificationMessage(
"Broadcast Auto-Enabled",
"Your Lightfinder broadcast has been automatically enabled.",
NotificationType.Info));
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -391,9 +401,14 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
public async void ToggleBroadcast() public async void ToggleBroadcast()
{ {
if (!IsLightFinderAvailable) if (!IsLightFinderAvailable)
{ {
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available."); _logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
_mediator.Publish(new NotificationMessage(
"Broadcast Unavailable",
"Lightfinder is not available on this server.",
NotificationType.Error));
return; return;
} }
@@ -403,6 +418,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero) if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
{ {
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds); _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; return;
} }
@@ -427,10 +446,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus); _logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
_mediator.Publish(new EnableBroadcastMessage(hashedCid, 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to determine current broadcast status for toggle"); _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); }).ConfigureAwait(false);
} }
@@ -493,6 +521,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{ {
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally."); _logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
ApplyBroadcastDisabled(forcePublish: true); ApplyBroadcastDisabled(forcePublish: true);
ShowBroadcastExpiredNotification();
} }
} }
else else
@@ -501,4 +530,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
} }
}).ConfigureAwait(false); }).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));
}
} }

View File

@@ -6,7 +6,11 @@ using LightlessSync.UI;
using LightlessSync.Utils; using LightlessSync.Utils;
using Lumina.Data.Files; using Lumina.Data.Files;
using Microsoft.Extensions.Logging; 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; namespace LightlessSync.Services;
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
@@ -16,6 +20,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
private CancellationTokenSource? _analysisCts; private CancellationTokenSource? _analysisCts;
private CancellationTokenSource _baseAnalysisCts = new(); private CancellationTokenSource _baseAnalysisCts = new();
private string _lastDataHash = string.Empty; private string _lastDataHash = string.Empty;
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer) public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
: base(logger, mediator) : base(logger, mediator)
@@ -34,6 +39,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public bool IsAnalysisRunning => _analysisCts != null; public bool IsAnalysisRunning => _analysisCts != null;
public int TotalFiles { get; internal set; } public int TotalFiles { get; internal set; }
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = []; internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
public CharacterAnalysisSummary LatestSummary => _latestSummary;
public void CancelAnalyze() public void CancelAnalyze()
{ {
@@ -80,6 +86,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
} }
} }
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage()); Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose(); _analysisCts.CancelDispose();
@@ -137,11 +145,39 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
LastAnalysis[obj.Key] = data; LastAnalysis[obj.Key] = data;
} }
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage()); Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value; _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() private void PrintAnalysis()
{ {
if (LastAnalysis.Count == 0) return; if (LastAnalysis.Count == 0) return;
@@ -233,3 +269,23 @@ 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);
}

View File

@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
public sealed class CommandManagerService : IDisposable 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 ApiController _apiController;
private readonly ICommandManager _commandManager; private readonly ICommandManager _commandManager;
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
_apiController = apiController; _apiController = apiController;
_mediator = mediator; _mediator = mediator;
_lightlessConfigService = lightlessConfigService; _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 + HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
"Additionally possible commands:" + Environment.NewLine + "Additionally possible commands:" + Environment.NewLine +
@@ -49,7 +54,8 @@ public sealed class CommandManagerService : IDisposable
public void Dispose() public void Dispose()
{ {
_commandManager.RemoveHandler(_commandName); _commandManager.RemoveHandler(_longName);
_commandManager.RemoveHandler(_shortName);
} }
private void OnCommand(string command, string args) private void OnCommand(string command, string args)

View 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();
}
}
}

View File

@@ -98,7 +98,7 @@ internal class ContextMenuService : IHostedService
if (targetData == null || targetData.Address == nint.Zero) if (targetData == null || targetData.Address == nint.Zero)
return; 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) if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
return; return;
@@ -116,7 +116,7 @@ internal class ContextMenuService : IHostedService
args.AddMenuItem(new MenuItem args.AddMenuItem(new MenuItem
{ {
Name = "Send Pair Request", Name = "Send Direct Pair Request",
PrefixChar = 'L', PrefixChar = 'L',
UseDefaultPrefix = false, UseDefaultPrefix = false,
PrefixColor = 708, 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) .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)]; .Select(u => (ulong)u.PlayerCharacterId)];

View 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

View File

@@ -1,6 +1,6 @@
namespace LightlessSync.Services; 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[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture)); public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));

View File

@@ -48,26 +48,30 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
public record HonorificReadyMessage : MessageBase; public record HonorificReadyMessage : MessageBase;
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase; public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
public record HaltScanMessage(string Source) : MessageBase; public record HaltScanMessage(string Source) : MessageBase;
public record ResumeScanMessage(string Source) : MessageBase;
public record NotificationMessage public record NotificationMessage
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase; (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 CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage; public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage; public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase; public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase; public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
public record ClearAllNotificationsMessage : MessageBase;
public record CharacterDataAnalyzedMessage : MessageBase; public record CharacterDataAnalyzedMessage : MessageBase;
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase; public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase; public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage; public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage; public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : SameThreadMessage; public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase; public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase; public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase; public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase; public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : 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 CyclePauseMessage(UserData UserData) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase; public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : 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 EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase;
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
public record PairRequestsUpdatedMessage : MessageBase; public record PairRequestsUpdatedMessage : MessageBase;
public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase;
public record VisibilityChange : MessageBase; public record VisibilityChange : MessageBase;
#pragma warning restore S2094 #pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name #pragma warning restore MA0048 // File name must match type name

View File

@@ -1,5 +1,6 @@
using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework; 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! // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Text;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -32,10 +34,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator; public LightlessMediator Mediator => _mediator;
private bool mEnabled = false; private bool _mEnabled = false;
private bool _needsLabelRefresh = false; private bool _needsLabelRefresh = false;
private AddonNamePlate* mpNameplateAddon = null; private AddonNamePlate* _mpNameplateAddon = null;
private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = 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; internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder"; private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; 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 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) 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(); DisableNameplate();
DestroyNameplateNodes(); DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this); _mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
mpNameplateAddon = null; _mpNameplateAddon = null;
} }
internal void EnableNameplate() internal void EnableNameplate()
{ {
if (!mEnabled) if (!_mEnabled)
{ {
try try
{ {
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
mEnabled = true; _mEnabled = true;
} }
catch (Exception e) catch (Exception e)
{ {
@@ -96,7 +98,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
internal void DisableNameplate() internal void DisableNameplate()
{ {
if (mEnabled) if (_mEnabled)
{ {
try try
{ {
@@ -107,24 +109,30 @@ public unsafe class NameplateHandler : IMediatorSubscriber
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
} }
mEnabled = false; _mEnabled = false;
HideAllNameplateNodes(); HideAllNameplateNodes();
} }
} }
private void NameplateDrawDetour(AddonEvent type, AddonArgs args) 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; 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(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
mpNameplateAddon = pNameplateAddon; _mpNameplateAddon = pNameplateAddon;
if (mpNameplateAddon != null) CreateNameplateNodes(); if (_mpNameplateAddon != null) CreateNameplateNodes();
} }
UpdateNameplateNodes(); UpdateNameplateNodes();
@@ -138,7 +146,16 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateObject == null) if (nameplateObject == null)
continue; continue;
var rootNode = nameplateObject.Value.RootComponentNode;
if (rootNode == null || rootNode->Component == null)
continue;
var pNameplateResNode = nameplateObject.Value.NameContainer; 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); var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
if (pNewNode != null) if (pNewNode != null)
@@ -148,24 +165,43 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNewNode->AtkResNode.NextSiblingNode = pLastChild; pNewNode->AtkResNode.NextSiblingNode = pLastChild;
pNewNode->AtkResNode.ParentNode = pNameplateResNode; pNewNode->AtkResNode.ParentNode = pNameplateResNode;
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); rootNode->Component->UldManager.UpdateDrawNodeList();
pNewNode->AtkResNode.SetUseDepthBasedPriority(true); pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
mTextNodes[i] = pNewNode; _mTextNodes[i] = pNewNode;
} }
} }
} }
private void DestroyNameplateNodes() private void DestroyNameplateNodes()
{ {
var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon) if (currentHandle.Address == nint.Zero)
{
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return; 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) for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{ {
var pTextNode = mTextNodes[i]; var pTextNode = _mTextNodes[i];
var pNameplateNode = GetNameplateComponentNode(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 try
{ {
@@ -175,7 +211,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
pNameplateNode->Component->UldManager.UpdateDrawNodeList(); pNameplateNode->Component->UldManager.UpdateDrawNodeList();
pTextNode->AtkResNode.Destroy(true); pTextNode->AtkResNode.Destroy(true);
mTextNodes[i] = null; _mTextNodes[i] = null;
} }
catch (Exception e) catch (Exception e)
{ {
@@ -192,7 +228,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void HideAllNameplateNodes() private void HideAllNameplateNodes()
{ {
for (int i = 0; i < mTextNodes.Length; ++i) for (int i = 0; i < _mTextNodes.Length; ++i)
{ {
HideNameplateTextNode(i); HideNameplateTextNode(i);
} }
@@ -200,16 +236,62 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void UpdateNameplateNodes() private void UpdateNameplateNodes()
{ {
var framework = Framework.Instance(); var currentHandle = _gameGui.GetAddonByName("NamePlate");
var ui3DModule = framework->GetUIModule()->GetUI3DModule(); 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) if (ui3DModule == null)
{
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return; return;
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) var visibleUserIdsSnapshot = VisibleUserIds;
{
var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value;
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) if (objectInfo == null || objectInfo->GameObject == null)
continue; continue;
@@ -217,62 +299,68 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue; continue;
var pNode = mTextNodes[nameplateIndex]; var pNode = _mTextNodes[nameplateIndex];
if (pNode == null) if (pNode == null)
continue; continue;
if (mpNameplateAddon == null) var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue; continue;
}
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); // CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid)) if (cid == null || !_activeBroadcastingCids.Contains(cid))
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(enable: false);
continue; 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; 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); pNode->AtkResNode.ToggleVisibility(enable: 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);
continue; continue;
} }
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer; var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText; 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; 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 labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge"); var edgeColor = UIColors.Get("LightfinderEdge");
var config = _configService.Current;
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
@@ -431,7 +519,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
positionY += config.LightfinderLabelOffsetY; positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(true); pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
pNode->AtkResNode.Color.A = 255; pNode->AtkResNode.Color.A = 255;
@@ -539,7 +627,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
} }
private void HideNameplateTextNode(int i) private void HideNameplateTextNode(int i)
{ {
var pNode = mTextNodes[i]; var pNode = _mTextNodes[i];
if (pNode != null) if (pNode != null)
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(false);
@@ -549,10 +637,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
{ {
if (i < AddonNamePlate.NumNamePlateObjects && if (i < AddonNamePlate.NumNamePlateObjects &&
mpNameplateAddon != null && _mpNameplateAddon != null &&
mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) _mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
{ {
return mpNameplateAddon->NamePlateObjectArray[i]; return _mpNameplateAddon->NamePlateObjectArray[i];
} }
else else
{ {
@@ -565,10 +653,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var nameplateObject = GetNameplateObject(i); var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)]; .Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh() public void FlagRefresh()
{ {
_needsLabelRefresh = true; _needsLabelRefresh = true;
@@ -585,18 +675,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
public void UpdateBroadcastingCids(IEnumerable<string> cids) public void UpdateBroadcastingCids(IEnumerable<string> cids)
{ {
var newSet = cids.ToHashSet(); var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
var changed = !_activeBroadcastingCids.SetEquals(newSet);
if (!changed)
return; return;
_activeBroadcastingCids.Clear(); _activeBroadcastingCids = newSet;
foreach (var cid in newSet) _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
_activeBroadcastingCids.Add(cid);
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids));
FlagRefresh(); FlagRefresh();
} }

View File

@@ -10,9 +10,11 @@ using LightlessSync.UI.Models;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using LightlessSync.API.Data;
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType; using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
namespace LightlessSync.Services; namespace LightlessSync.Services;
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
{ {
private readonly ILogger<NotificationService> _logger; private readonly ILogger<NotificationService> _logger;
@@ -43,7 +45,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage); Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<PairRequestReceivedMessage>(this, HandlePairRequestReceived);
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated); Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
Mediator.Subscribe<PairDownloadStatusMessage>(this, HandlePairDownloadStatus);
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -107,23 +112,42 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
{ {
var notification = new LightlessNotification var location = GetNotificationLocation(NotificationType.PairRequest);
{
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) // Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{ {
PlayNotificationSound(notification.SoundEffectId.Value); ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
} }
Mediator.Publish(new LightlessNotificationMessage(notification)); // 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)
};
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() => private uint? GetPairRequestSoundId() =>
@@ -271,33 +295,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return actions; 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 private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads,
{
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,
int queueWaiting) int queueWaiting)
{ {
var messageParts = new List<string>(); var messageParts = new List<string>();
@@ -309,7 +308,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
if (userDownloads.Count > 0) 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"); messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
} }
@@ -322,33 +321,27 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return string.Join("\n", messageParts); 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 var activeDownloads = userDownloads
.Where(x => x.progress < 1.0f) .Where(x => x.Progress < 1.0f)
.Take(_configService.Current.MaxConcurrentPairApplications); .Take(_configService.Current.MaxConcurrentPairApplications);
if (!activeDownloads.Any()) return string.Empty; 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) => private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) =>
download.status switch download.Status switch
{ {
"downloading" => $"{download.progress:P0}", "downloading" => $"{download.Progress:P0}",
"decompressing" => "decompressing", "decompressing" => "decompressing",
"queued" => "queued", "queued" => "queued",
"waiting" => "waiting for slot", "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 private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{ {
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
@@ -356,6 +349,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
_ => TimeSpan.FromSeconds(10) _ => TimeSpan.FromSeconds(10)
}; };
@@ -371,7 +365,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.DisableInfoSound, NotificationType.Info => _configService.Current.DisableInfoSound,
NotificationType.Warning => _configService.Current.DisableWarningSound, NotificationType.Warning => _configService.Current.DisableWarningSound,
NotificationType.Error => _configService.Current.DisableErrorSound, NotificationType.Error => _configService.Current.DisableErrorSound,
NotificationType.Download => _configService.Current.DisableDownloadSound, NotificationType.Performance => _configService.Current.DisablePerformanceSound,
NotificationType.Download => true, // Download sounds always disabled
_ => false _ => false
}; };
@@ -380,7 +375,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Info => _configService.Current.CustomInfoSoundId,
NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId,
NotificationType.Error => _configService.Current.CustomErrorSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId,
NotificationType.Download => _configService.Current.DownloadSoundId, NotificationType.Performance => _configService.Current.PerformanceSoundId,
_ => NotificationSounds.GetDefaultSound(type) _ => NotificationSounds.GetDefaultSound(type)
}; };
@@ -418,6 +413,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => _configService.Current.LightlessErrorNotification, NotificationType.Error => _configService.Current.LightlessErrorNotification,
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
NotificationType.Download => _configService.Current.LightlessDownloadNotification, NotificationType.Download => _configService.Current.LightlessDownloadNotification,
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
_ => NotificationLocation.LightlessUi _ => NotificationLocation.LightlessUi
}; };
@@ -505,6 +501,18 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
case NotificationType.Error: case NotificationType.Error:
PrintErrorChat(msg.Message); PrintErrorChat(msg.Message);
break; 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); _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 _) private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{ {
var activeRequests = _pairRequestService.GetActiveRequests(); var activeRequests = _pairRequestService.GetActiveRequests();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(); 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 var notificationsToRemove = _shownPairRequestNotifications
.Where(hashedCid => !activeRequestIds.Contains(hashedCid)) .Where(hashedCid => !activeRequestIds.Contains(hashedCid))
.ToList(); .ToList();
@@ -544,18 +581,165 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationDismissMessage(notificationId)); Mediator.Publish(new LightlessNotificationDismissMessage(notificationId));
_shownPairRequestNotifications.Remove(hashedCid); _shownPairRequestNotifications.Remove(hashedCid);
} }
}
// Show/update notifications for all active requests private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
foreach (var request in activeRequests) {
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); Id = "pair_download_progress",
ShowPairRequestNotification( Title = "Downloading Pair Data",
request.DisplayName, Message = message,
request.HashedCid, Type = NotificationType.Download,
() => _pairRequestService.AcceptPairRequest(request.HashedCid, request.DisplayName), Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
() => _pairRequestService.DeclinePairRequest(request.HashedCid) 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})";
}
}

View File

@@ -15,6 +15,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _semaphore;
private int _currentLimit; private int _currentLimit;
private int _pendingReductions; private int _pendingReductions;
private int _pendingIncrements;
private int _waiting; private int _waiting;
private int _inFlight; private int _inFlight;
@@ -70,7 +71,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!IsEnabled) if (!IsEnabled)
{ {
_semaphore.Release(); TryReleaseSemaphore();
return NoopReleaser.Instance; return NoopReleaser.Instance;
} }
@@ -90,18 +91,12 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
var releaseAmount = HardLimit - _semaphore.CurrentCount; var releaseAmount = HardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0) if (releaseAmount > 0)
{ {
try TryReleaseSemaphore(releaseAmount);
{
_semaphore.Release(releaseAmount);
}
catch (SemaphoreFullException)
{
// ignore, already at max
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
_pendingReductions = 0; _pendingReductions = 0;
_pendingIncrements = 0;
return; return;
} }
@@ -113,10 +108,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (desiredLimit > _currentLimit) if (desiredLimit > _currentLimit)
{ {
var increment = desiredLimit - _currentLimit; var increment = desiredLimit - _currentLimit;
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount); _pendingIncrements += increment;
if (allowed > 0)
var available = HardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{ {
_semaphore.Release(allowed); _pendingIncrements -= toRelease;
} }
} }
else else
@@ -133,6 +131,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{ {
_pendingReductions += remaining; _pendingReductions += remaining;
} }
if (_pendingIncrements > 0)
{
var offset = Math.Min(_pendingIncrements, _pendingReductions);
_pendingIncrements -= offset;
_pendingReductions -= offset;
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
@@ -146,6 +151,25 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
return Math.Clamp(configured, 1, HardLimit); 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() private void ReleaseOne()
{ {
var inFlight = Interlocked.Decrement(ref _inFlight); var inFlight = Interlocked.Decrement(ref _inFlight);
@@ -166,9 +190,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
_pendingReductions--; _pendingReductions--;
return; return;
} }
if (_pendingIncrements > 0)
{
if (!TryReleaseSemaphore())
{
return;
}
_pendingIncrements--;
return;
}
} }
_semaphore.Release(); TryReleaseSemaphore();
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

@@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -14,12 +10,17 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly Lazy<WebAPI.ApiController> _apiController; private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly object _syncRoot = new(); private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = []; 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) : base(logger, mediator)
{ {
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
@@ -184,7 +185,7 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
} }
var now = DateTime.UtcNow; 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) 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); 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); Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
} }

View File

@@ -78,23 +78,26 @@ public class PlayerPerformanceService
string warningText = string.Empty; string warningText = string.Empty;
if (exceedsTris && !exceedsVram) if (exceedsTris && !exceedsVram)
{ {
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold (" + warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles)."; $"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
} }
else if (!exceedsTris) else if (!exceedsTris)
{ {
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold (" + warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB)."; $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
} }
else else
{ {
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold (" + 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 " + $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
$"triangle warning threshold ({triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
} }
_mediator.Publish(new NotificationMessage($"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)", _mediator.Publish(new PerformanceNotificationMessage(
warningText, LightlessConfiguration.Models.NotificationType.Warning)); $"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
warningText,
pairHandler.Pair.UserData,
pairHandler.Pair.IsPaused,
pairHandler.Pair.PlayerName));
} }
return true; return true;
@@ -138,11 +141,15 @@ public class PlayerPerformanceService
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000, if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{ {
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused", var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold (" + $"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)" +
$" and has been automatically paused.", _mediator.Publish(new PerformanceNotificationMessage(
LightlessConfiguration.Models.NotificationType.Warning)); $"{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, _mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)"))); $"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, if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{ {
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused", var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold (" + $"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB)" +
$" and has been automatically paused.", _mediator.Publish(new PerformanceNotificationMessage(
LightlessConfiguration.Models.NotificationType.Warning)); $"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
true,
pair.PlayerName));
_mediator.Publish(new PauseMessage(pair.UserData)); _mediator.Publish(new PauseMessage(pair.UserData));

View File

@@ -1,4 +1,5 @@
using LightlessSync.API.Dto.Group; using Dalamud.Interface.ImGuiFileDialog;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
@@ -18,10 +19,11 @@ public class UiFactory
private readonly ServerConfigurationManager _serverConfigManager; private readonly ServerConfigurationManager _serverConfigManager;
private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessProfileManager _lightlessProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
private readonly FileDialogManager _fileDialogManager;
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController, public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager, UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService) LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -31,12 +33,13 @@ public class UiFactory
_serverConfigManager = serverConfigManager; _serverConfigManager = serverConfigManager;
_lightlessProfileManager = lightlessProfileManager; _lightlessProfileManager = lightlessProfileManager;
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_fileDialogManager = fileDialogManager;
} }
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{ {
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator, return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService); _apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
} }
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -23,8 +23,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
LightlessConfigService lightlessConfigService, WindowSystem windowSystem, LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
IEnumerable<WindowMediatorSubscriberBase> windows, IEnumerable<WindowMediatorSubscriberBase> windows,
UiFactory uiFactory, FileDialogManager fileDialogManager, UiFactory uiFactory, FileDialogManager fileDialogManager,
LightlessMediator lightlessMediator, LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
NotificationService notificationService) : base(logger, lightlessMediator)
{ {
_logger = logger; _logger = logger;
_logger.LogTrace("Creating {type}", GetType().Name); _logger.LogTrace("Creating {type}", GetType().Name);

View File

@@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (!_charaDataManager.BrioAvailable) if (!_charaDataManager.BrioAvailable)
{ {
ImGuiHelpers.ScaledDummy(3); 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(); UiSharedService.DistanceSeparator();
} }

View File

@@ -1,4 +1,3 @@
using System;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
@@ -16,12 +15,14 @@ using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components; using LightlessSync.UI.Components;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR.Utils; using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
@@ -56,7 +57,6 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly BroadcastService _broadcastService; private readonly BroadcastService _broadcastService;
private List<IDrawFolder> _drawFolders; private List<IDrawFolder> _drawFolders;
private Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>>? _cachedAnalysis;
private Pair? _lastAddedUser; private Pair? _lastAddedUser;
private string _lastAddedUserComment = string.Empty; private string _lastAddedUserComment = string.Empty;
private Vector2 _lastPosition = Vector2.One; private Vector2 _lastPosition = Vector2.One;
@@ -382,15 +382,26 @@ public class CompactUi : WindowMediatorSubscriberBase
_uiSharedService.IconText(FontAwesomeIcon.Upload); _uiSharedService.IconText(FontAwesomeIcon.Upload);
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); 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); foreach (var upload in currentUploads)
var activeUploads = currentUploads.Count(c => !c.IsTransferred); {
if (upload.IsTransferred)
{
doneUploads++;
}
totalUploaded += upload.Transferred;
totalToUpload += upload.Total;
}
int activeUploads = totalUploads - doneUploads;
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); 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})"); ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})");
var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})";
@@ -405,17 +416,17 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted("No uploads in progress"); ImGui.TextUnformatted("No uploads in progress");
} }
var currentDownloads = BuildCurrentDownloadSnapshot(); var downloadSummary = GetDownloadSummary();
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
_uiSharedService.IconText(FontAwesomeIcon.Download); _uiSharedService.IconText(FontAwesomeIcon.Download);
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
if (currentDownloads.Any()) if (downloadSummary.HasDownloads)
{ {
var totalDownloads = currentDownloads.Sum(c => c.TotalFiles); var totalDownloads = downloadSummary.TotalFiles;
var doneDownloads = currentDownloads.Sum(c => c.TransferredFiles); var doneDownloads = downloadSummary.TransferredFiles;
var totalDownloaded = currentDownloads.Sum(c => c.TransferredBytes); var totalDownloaded = downloadSummary.TransferredBytes;
var totalToDownload = currentDownloads.Sum(c => c.TotalBytes); var totalToDownload = downloadSummary.TotalBytes;
ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}"); ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}");
var downloadText = 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()) foreach (var kvp in _currentDownloads.ToArray())
{ {
var value = kvp.Value; if (kvp.Value is not { Count: > 0 } statuses)
if (value == null || value.Count == 0) {
continue; 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() 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. //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; Vector2 uidTextSize, iconSize;
using (_uiSharedService.UidFont.Push()) using (_uiSharedService.UidFont.Push())
@@ -509,6 +528,7 @@ public class CompactUi : WindowMediatorSubscriberBase
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
{ {
ImGui.BeginTooltip(); ImGui.BeginTooltip();
ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f);
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue")); ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
ImGui.Text("Lightfinder"); ImGui.Text("Lightfinder");
@@ -556,6 +576,7 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.PopStyleColor(); ImGui.PopStyleColor();
} }
ImGui.PopTextWrapPos();
ImGui.EndTooltip(); ImGui.EndTooltip();
} }
@@ -574,7 +595,7 @@ public class CompactUi : WindowMediatorSubscriberBase
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos(); var cursorPos = ImGui.GetCursorScreenPos();
var fontPtr = ImGui.GetFont(); var fontPtr = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr); SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-header");
} }
else else
{ {
@@ -591,56 +612,40 @@ public class CompactUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Click to copy"); 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 objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
var valueDict = firstEntry.Value; if (objectSummary.HasEntries)
if (valueDict != null && valueDict.Count > 0)
{ {
var groupedfiles = valueDict var actualVramUsage = objectSummary.TexOriginalBytes;
.Select(v => v.Value) var actualTriCount = objectSummary.TotalTriangles;
.Where(v => v != null)
.GroupBy(f => f.FileType, StringComparer.Ordinal)
.OrderBy(k => k.Key, StringComparer.Ordinal)
.ToList();
var actualTriCount = valueDict var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
.Select(v => v.Value) var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
.Where(v => v != null)
.Sum(f => f.Triangles);
if (groupedfiles != null) if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
{ {
//Checking of VRAM threshhold ImGui.SameLine();
var texGroup = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); ImGui.SetCursorPosY(cursorY + 15f);
var actualVramUsage = texGroup != null ? texGroup.Sum(f => f.OriginalSize) : 0L; _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) string warningMessage = "";
if (isOverTriHold)
{ {
ImGui.SameLine(); warningMessage += $"You exceed your own triangles threshold by " +
ImGui.SetCursorPosY(cursorY + 15f); $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); warningMessage += Environment.NewLine;
string warningMessage = ""; }
if (isOverTriHold) if (isOverVRAMUsage)
{ {
warningMessage += $"You exceed your own triangles threshold by " + warningMessage += $"You exceed your own VRAM threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
warningMessage += Environment.NewLine; }
UiSharedService.AttachToolTip(warningMessage);
} if (ImGui.IsItemClicked())
if (isOverVRAMUsage) {
{ _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
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 seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos(); var cursorPos = ImGui.GetCursorScreenPos();
var fontPtr = ImGui.GetFont(); var fontPtr = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr); SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-footer");
} }
else else
{ {
@@ -704,23 +709,23 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
//Filter of not foldered syncshells //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)) 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); GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
if (FilterNotTaggedSyncshells(group)) 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) //Filter of grouped up syncshells (All Syncshells Folder)
if (_configService.Current.GroupUpSyncshells) if (_configService.Current.GroupUpSyncshells)
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService, drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService,
_selectSyncshellForTagUi, _renameSyncshellTagUi, "")); _selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
else else
drawFolders.AddRange(groupFolders); drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder));
//Filter of grouped/foldered pairs //Filter of grouped/foldered pairs
foreach (var tag in _tagHandler.GetAllPairTagsSorted()) foreach (var tag in _tagHandler.GetAllPairTagsSorted())
@@ -734,7 +739,7 @@ public class CompactUi : WindowMediatorSubscriberBase
//Filter of grouped/foldered syncshells //Filter of grouped/foldered syncshells
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted()) 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)) foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{ {
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag)) if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
@@ -743,11 +748,11 @@ public class CompactUi : WindowMediatorSubscriberBase
out ImmutableList<Pair> allGroupPairs, out ImmutableList<Pair> allGroupPairs,
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs); 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 //Filter of not grouped/foldered and offline pairs

View File

@@ -1,7 +1,11 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
@@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components;
public class DrawGroupedGroupFolder : IDrawFolder public class DrawGroupedGroupFolder : IDrawFolder
{ {
private readonly string _tag; private readonly string _tag;
private readonly IEnumerable<IDrawFolder> _groups; private readonly IEnumerable<GroupFolder> _groups;
private readonly TagHandler _tagHandler; private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private readonly ApiController _apiController;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private bool _wasHovered = false; private bool _wasHovered = false;
private float _menuWidth; private float _menuWidth;
public IImmutableList<DrawUserPair> DrawPairs => throw new NotSupportedException(); public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); 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.TotalPairs); 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; _groups = groups;
_tagHandler = tagHandler; _tagHandler = tagHandler;
@@ -30,6 +35,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
_selectSyncshellForTagUi = selectSyncshellForTagUi; _selectSyncshellForTagUi = selectSyncshellForTagUi;
_renameSyncshellTagUi = renameSyncshellTagUi; _renameSyncshellTagUi = renameSyncshellTagUi;
_tag = tag; _tag = tag;
_apiController = apiController;
} }
public void Draw() public void Draw()
@@ -42,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var id = ImRaii.PushId(_id); using var id = ImRaii.PushId(_id);
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); 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())); ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f))) using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
@@ -83,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
{ {
ImGui.TextUnformatted(_tag); ImGui.TextUnformatted(_tag);
ImGui.SameLine();
DrawPauseButton();
ImGui.SameLine(); ImGui.SameLine();
DrawMenu(); DrawMenu();
} else } else
{ {
ImGui.TextUnformatted("All Syncshells"); ImGui.TextUnformatted("All Syncshells");
ImGui.SameLine();
DrawPauseButton();
} }
} }
color.Dispose(); color.Dispose();
@@ -100,11 +111,49 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var indent = ImRaii.PushIndent(20f); using var indent = ImRaii.PushIndent(20f);
foreach (var entry in _groups) 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() protected void DrawMenu()
{ {
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV); var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);

View File

@@ -2,6 +2,7 @@
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
@@ -13,6 +14,9 @@ using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
namespace LightlessSync.UI.Components; namespace LightlessSync.UI.Components;
@@ -32,6 +36,8 @@ public class DrawUserPair
private readonly CharaDataManager _charaDataManager; private readonly CharaDataManager _charaDataManager;
private float _menuWidth = -1; private float _menuWidth = -1;
private bool _wasHovered = false; private bool _wasHovered = false;
private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty;
private string _cachedTooltip = string.Empty;
public DrawUserPair(string id, Pair entry, List<GroupFullInfoDto> syncedGroups, public DrawUserPair(string id, Pair entry, List<GroupFullInfoDto> syncedGroups,
GroupFullInfoDto? currentGroup, GroupFullInfoDto? currentGroup,
@@ -190,15 +196,12 @@ public class DrawUserPair
private void DrawLeftSide() private void DrawLeftSide()
{ {
string userPairText = string.Empty;
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
if (_pair.IsPaused) if (_pair.IsPaused)
{ {
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
_uiSharedService.IconText(FontAwesomeIcon.PauseCircle); _uiSharedService.IconText(FontAwesomeIcon.PauseCircle);
userPairText = _pair.UserData.AliasOrUID + " is paused";
} }
else if (!_pair.IsOnline) else if (!_pair.IsOnline)
{ {
@@ -207,12 +210,10 @@ public class DrawUserPair
? FontAwesomeIcon.ArrowsLeftRight ? FontAwesomeIcon.ArrowsLeftRight
: (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional : (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
? FontAwesomeIcon.User : FontAwesomeIcon.Users)); ? FontAwesomeIcon.User : FontAwesomeIcon.Users));
userPairText = _pair.UserData.AliasOrUID + " is offline";
} }
else if (_pair.IsVisible) else if (_pair.IsVisible)
{ {
_uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue")); _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()) if (ImGui.IsItemClicked())
{ {
_mediator.Publish(new TargetPairMessage(_pair)); _mediator.Publish(new TargetPairMessage(_pair));
@@ -223,46 +224,9 @@ public class DrawUserPair
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("PairBlue")); using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
_uiSharedService.IconText(_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional _uiSharedService.IconText(_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
? FontAwesomeIcon.User : FontAwesomeIcon.Users); ? FontAwesomeIcon.User : FontAwesomeIcon.Users);
userPairText = _pair.UserData.AliasOrUID + " is online";
} }
if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided) UiSharedService.AttachToolTip(GetUserTooltip());
{
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);
if (_performanceConfigService.Current.ShowPerformanceIndicator if (_performanceConfigService.Current.ShowPerformanceIndicator
&& !_performanceConfigService.Current.UIDsToIgnore && !_performanceConfigService.Current.UIDsToIgnore
@@ -327,6 +291,143 @@ public class DrawUserPair
_displayHandler.DrawPairText(_id, _pair, leftSide, () => rightSide - leftSide); _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() private void DrawPairedClientMenu()
{ {
DrawIndividualMenu(); DrawIndividualMenu();

View File

@@ -1,5 +1,4 @@
 using System.Collections.Immutable;
using System.Collections.Immutable;
namespace LightlessSync.UI.Components; namespace LightlessSync.UI.Components;

View File

@@ -22,12 +22,12 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new(); private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
private readonly NotificationService _notificationService;
private bool _notificationDismissed = true; private bool _notificationDismissed = true;
private int _lastDownloadStateHash = 0;
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared,
PerformanceCollectorService performanceCollectorService, NotificationService notificationService) PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService) : base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
{ {
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
@@ -35,7 +35,6 @@ public class DownloadUi : WindowMediatorSubscriberBase
_pairProcessingLimiter = pairProcessingLimiter; _pairProcessingLimiter = pairProcessingLimiter;
_fileTransferManager = fileTransferManager; _fileTransferManager = fileTransferManager;
_uiShared = uiShared; _uiShared = uiShared;
_notificationService = notificationService;
SizeConstraints = new WindowSizeConstraints() SizeConstraints = new WindowSizeConstraints()
{ {
@@ -59,13 +58,21 @@ public class DownloadUi : WindowMediatorSubscriberBase
IsOpen = true; 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) => Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
{ {
_currentDownloads.TryRemove(msg.DownloadId, out _); _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); Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
@@ -116,7 +123,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
} }
catch catch
{ {
// ignore errors thrown from UI _logger.LogDebug("Error drawing upload progress");
} }
try try
@@ -131,17 +138,19 @@ public class DownloadUi : WindowMediatorSubscriberBase
// Use notification system // Use notification system
if (_currentDownloads.Any()) if (_currentDownloads.Any())
{ {
UpdateDownloadNotification(limiterSnapshot); UpdateDownloadNotificationIfChanged(limiterSnapshot);
_notificationDismissed = false; _notificationDismissed = false;
} }
else if (!_notificationDismissed) else if (!_notificationDismissed)
{ {
_notificationService.DismissPairDownloadNotification(); Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
_notificationDismissed = true; _notificationDismissed = true;
_lastDownloadStateHash = 0;
} }
} }
else else
{ {
// Use text overlay
if (limiterSnapshot.IsEnabled) if (limiterSnapshot.IsEnabled)
{ {
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
@@ -183,7 +192,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
} }
catch catch
{ {
// ignore errors thrown from UI _logger.LogDebug("Error drawing download progress");
} }
} }
@@ -255,7 +264,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
} }
catch 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 dlSlot = 0;
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); var dlQueue = 0;
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); var dlProg = 0;
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); var dlDecomp = 0;
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); long totalBytes = 0;
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); long transferredBytes = 0;
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); // 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; var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
@@ -323,14 +346,27 @@ public class DownloadUi : WindowMediatorSubscriberBase
else status = "completed"; else status = "completed";
downloadStatus.Add((item.Key.Name, progress, status)); 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; 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));
}
} }
} }
} }

View File

@@ -2,19 +2,22 @@ using Dalamud.Game.Gui.Dtr;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils; using LightlessSync.WebAPI.SignalR.Utils;
using LightlessSync.Utils;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using static LightlessSync.Services.PairRequestService;
namespace LightlessSync.UI; namespace LightlessSync.UI;
@@ -106,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_logger.LogInformation("Lightfinder operation was canceled.");
} }
finally finally
{ {
@@ -363,29 +366,46 @@ public sealed class DtrEntry : IDisposable, IHostedService
} }
} }
private int GetNearbyBroadcastCount() private List<string> GetNearbyBroadcasts()
{
var localHashedCid = GetLocalHashedCid();
return _broadcastScannerService.CountActiveBroadcasts(
string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid);
}
private int GetPendingPairRequestCount()
{ {
try 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) catch (Exception ex)
{ {
var now = DateTime.UtcNow; 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) if (now >= _pairRequestNextErrorLog)
{ {
_logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry."); _logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry.");
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
} }
return 0; return [];
} }
} }
@@ -400,23 +420,15 @@ public sealed class DtrEntry : IDisposable, IHostedService
if (_broadcastService.IsBroadcasting) if (_broadcastService.IsBroadcasting)
{ {
var tooltipBuilder = new StringBuilder("Lightfinder - Enabled");
switch (config.LightfinderDtrDisplayMode) switch (config.LightfinderDtrDisplayMode)
{ {
case LightfinderDtrDisplayMode.PendingPairRequests: case LightfinderDtrDisplayMode.PendingPairRequests:
{ {
var requestCount = GetPendingPairRequestCount(); return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Pending pair requests: ").Append(requestCount);
return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
} }
default: default:
{ {
var broadcastCount = GetNearbyBroadcastCount(); return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount);
return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
} }
} }
} }
@@ -433,6 +445,18 @@ public sealed class DtrEntry : IDisposable, IHostedService
return ($"{icon} OFF", colors, tooltip.ToString()); 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) private static string BuildLightfinderTooltip(string baseTooltip)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();

View File

@@ -63,7 +63,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; }); Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; });
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen); Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen);
Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false); 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)) 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() protected override void DrawInternal()
{ {
_uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow")); _uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow"));
ImGui.Dummy(new Vector2(5)); ImGui.Dummy(new Vector2(5));
@@ -108,7 +109,8 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.Dummy(new Vector2(3)); 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")) if (ImGui.BeginTabBar("##EditProfileTabs"))
{ {
@@ -204,7 +206,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
} }
_showFileDialogError = false; _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); .ConfigureAwait(false);
}); });
}); });
@@ -213,7 +215,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture")) 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"); UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError) if (_showFileDialogError)
@@ -223,7 +225,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
var isNsfw = profile.IsNSFW; var isNsfw = profile.IsNSFW;
if (ImGui.Checkbox("Profile is NSFW", ref 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"); _uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
var widthTextBox = 400; var widthTextBox = 400;
@@ -262,13 +264,13 @@ public class EditProfileUi : WindowMediatorSubscriberBase
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) 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"); UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) 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"); UiSharedService.AttachToolTip("Clears your profile description text");
@@ -279,7 +281,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
{ {
_uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple")); _uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple"));
ImGui.Dummy(new Vector2(4)); 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; var hasVanity = _apiController.HasVanity;

View File

@@ -157,7 +157,7 @@ public class IdDisplayHandler
Vector2 textSize; Vector2 textSize;
using (ImRaii.PushFont(font, textIsUid)) using (ImRaii.PushFont(font, textIsUid))
{ {
SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font); SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID);
itemMin = ImGui.GetItemRectMin(); itemMin = ImGui.GetItemRectMin();
itemMax = ImGui.GetItemRectMax(); itemMax = ImGui.GetItemRectMax();
//textSize = itemMax - itemMin; //textSize = itemMax - itemMin;

View File

@@ -15,19 +15,25 @@ using Dalamud.Bindings.ImGui;
namespace LightlessSync.UI; namespace LightlessSync.UI;
public class LightlessNotificationUI : WindowMediatorSubscriberBase public class LightlessNotificationUi : WindowMediatorSubscriberBase
{ {
private const float NotificationMinHeight = 60f; private const float _notificationMinHeight = 60f;
private const float NotificationMaxHeight = 250f; private const float _notificationMaxHeight = 250f;
private const float WindowPaddingOffset = 6f; private const float _windowPaddingOffset = 6f;
private const float SlideAnimationDistance = 100f; private const float _slideAnimationDistance = 100f;
private const float OutAnimationSpeedMultiplier = 0.7f; 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 List<LightlessNotification> _notifications = new();
private readonly object _notificationLock = new(); private readonly object _notificationLock = new();
private readonly LightlessConfigService _configService; 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) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
{ {
_configService = configService; _configService = configService;
@@ -39,6 +45,9 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoNav |
ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoBackground |
ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoInputs |
ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoScrollbar |
ImGuiWindowFlags.AlwaysAutoResize; ImGuiWindowFlags.AlwaysAutoResize;
PositionCondition = ImGuiCond.Always; PositionCondition = ImGuiCond.Always;
@@ -49,12 +58,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage); Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage); Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage);
Mediator.Subscribe<ClearAllNotificationsMessage>(this, HandleClearAllNotifications);
} }
private void HandleNotificationMessage(LightlessNotificationMessage message) => private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification);
AddNotification(message.Notification); private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId);
private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications();
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) =>
RemoveNotification(message.NotificationId);
public void AddNotification(LightlessNotification notification) public void AddNotification(LightlessNotification notification)
{ {
@@ -81,6 +89,13 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
existing.Progress = updated.Progress; existing.Progress = updated.Progress;
existing.ShowProgress = updated.ShowProgress; existing.ShowProgress = updated.ShowProgress;
existing.Title = updated.Title; 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); _logger.LogDebug("Updated existing notification: {Title}", updated.Title);
} }
@@ -96,12 +111,26 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
} }
} }
public void ClearAllNotifications()
{
lock (_notificationLock)
{
foreach (var notification in _notifications)
{
StartOutAnimation(notification);
}
}
}
private void StartOutAnimation(LightlessNotification notification) private void StartOutAnimation(LightlessNotification notification)
{ {
notification.IsAnimatingOut = true; notification.IsAnimatingOut = true;
notification.IsAnimatingIn = false; notification.IsAnimatingIn = false;
} }
private bool ShouldRemoveNotification(LightlessNotification notification) =>
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
protected override void DrawInternal() protected override void DrawInternal()
{ {
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
@@ -118,7 +147,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
} }
var viewport = ImGui.GetMainViewport(); var viewport = ImGui.GetMainViewport();
// Window auto-resizes based on content (AlwaysAutoResize flag)
Position = CalculateWindowPosition(viewport); Position = CalculateWindowPosition(viewport);
PositionCondition = ImGuiCond.Always;
DrawAllNotifications(); DrawAllNotifications();
} }
@@ -127,24 +160,32 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport) private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport)
{ {
var x = viewport.WorkPos.X + viewport.WorkSize.X - var corner = _configService.Current.NotificationCorner;
_configService.Current.NotificationWidth - var offsetX = _configService.Current.NotificationOffsetX;
_configService.Current.NotificationOffsetX - var width = _configService.Current.NotificationWidth;
WindowPaddingOffset;
var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY; float posX = corner == NotificationCorner.Left
return new Vector2(x, y); ? viewport.WorkPos.X + offsetX - _windowPaddingOffset
: viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset;
return new Vector2(posX, viewport.WorkPos.Y);
} }
private void DrawAllNotifications() private void DrawAllNotifications()
{ {
var offsetY = _configService.Current.NotificationOffsetY;
var startY = ImGui.GetCursorPosY() + offsetY;
for (int i = 0; i < _notifications.Count; i++) 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) private void UpdateAnimationsAndRemoveExpired(float deltaTime)
{ {
UpdateTargetYPositions();
for (int i = _notifications.Count - 1; i >= 0; i--) for (int i = _notifications.Count - 1; i >= 0; i--)
{ {
var notification = _notifications[i]; var notification = _notifications[i];
UpdateNotificationAnimation(notification, deltaTime); UpdateNotificationAnimation(notification, deltaTime);
UpdateNotificationYOffset(notification, deltaTime);
if (ShouldRemoveNotification(notification)) if (ShouldRemoveNotification(notification))
{ {
_notifications.RemoveAt(i); _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) private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime)
{ {
if (notification.IsAnimatingIn && notification.AnimationProgress < 1f) if (notification.IsAnimatingIn && notification.AnimationProgress < 1f)
@@ -196,7 +284,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f)
{ {
notification.AnimationProgress = Math.Max(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) else if (!notification.IsAnimatingOut && !notification.IsDismissed)
{ {
@@ -209,20 +297,24 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
} }
} }
private bool ShouldRemoveNotification(LightlessNotification notification) => private Vector2 CalculateSlideOffset(float alpha)
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; {
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) private void DrawNotification(LightlessNotification notification, int index)
{ {
var alpha = notification.AnimationProgress; var alpha = notification.AnimationProgress;
if (alpha <= 0f) return; if (alpha <= 0f) return;
var slideOffset = (1f - alpha) * SlideAnimationDistance; var slideOffset = CalculateSlideOffset(alpha);
var originalCursorPos = ImGui.GetCursorPos(); var originalCursorPos = ImGui.GetCursorPos();
ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); ImGui.SetCursorPos(originalCursorPos + slideOffset);
var notificationHeight = CalculateNotificationHeight(notification); var notificationHeight = CalculateNotificationHeight(notification);
var notificationWidth = _configService.Current.NotificationWidth - slideOffset; var notificationWidth = _configService.Current.NotificationWidth;
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
@@ -253,6 +345,13 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
DrawBackground(drawList, windowPos, windowSize, bgColor); DrawBackground(drawList, windowPos, windowSize, bgColor);
DrawAccentBar(drawList, windowPos, windowSize, accentColor); DrawAccentBar(drawList, windowPos, windowSize, accentColor);
DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList); 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); DrawNotificationText(notification, alpha);
} }
@@ -308,20 +407,33 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor) private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor)
{ {
var accentWidth = _configService.Current.NotificationAccentBarWidth; 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( accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0);
windowPos, accentEnd = windowPos + windowSize;
windowPos + new Vector2(accentWidth, windowSize.Y),
ImGui.ColorConvertFloat4ToU32(accentColor),
3f
);
} }
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) 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 progressBarColor = UIColors.Get("LightlessBlue");
var progressHeight = 2f; var progressHeight = 2f;
var progressY = windowPos.Y + windowSize.Y - progressHeight; 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) var progress = Math.Clamp(notification.Progress, 0f, 1f);
{ var progressBarColor = UIColors.Get("LightlessGreen");
return Math.Clamp(notification.Progress, 0f, 1f); 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; var elapsed = DateTime.UtcNow - notification.CreatedAt;
return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds));
} }
@@ -371,82 +496,113 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private void DrawNotificationText(LightlessNotification notification, float alpha) private void DrawNotificationText(LightlessNotification notification, float alpha)
{ {
var padding = new Vector2(10f, 6f); var contentPos = new Vector2(_contentPaddingX, _contentPaddingY);
var contentPos = new Vector2(padding.X, padding.Y);
var windowSize = ImGui.GetWindowSize(); 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); ImGui.SetCursorPos(contentPos);
var titleHeight = DrawTitle(notification, contentSize.X, alpha); var titleHeight = DrawTitle(notification, contentWidth, alpha);
DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha); DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha);
if (notification.Actions.Count > 0) if (HasActions(notification))
{ {
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y); PositionActionsAtBottom(windowSize.Y);
ImGui.SetCursorPosX(contentPos.X); DrawNotificationActions(notification, contentWidth, alpha);
DrawNotificationActions(notification, contentSize.X, 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) 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); return DrawWrappedText(titleText, 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;
} }
} }
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) private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha)
{ {
if (string.IsNullOrEmpty(notification.Message)) return; if (string.IsNullOrEmpty(notification.Message)) return;
ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f)); var messagePos = contentPos + new Vector2(0f, titleHeight + _titleMessageSpacing);
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth); var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha);
using (ImRaii.PushColor(ImGuiCol.Text, 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) private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha)
{ {
var buttonSpacing = 8f; var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth);
var rightPadding = 10f;
var usableWidth = availableWidth - rightPadding;
var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing;
var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count;
_logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}",
notification.Actions.Count, buttonWidth, availableWidth); notification.Actions.Count, buttonWidth, availableWidth);
var startCursorPos = ImGui.GetCursorPos(); var startX = ImGui.GetCursorPosX();
for (int i = 0; i < notification.Actions.Count; i++) for (int i = 0; i < notification.Actions.Count; i++)
{ {
var action = notification.Actions[i];
if (i > 0) if (i > 0)
{ {
ImGui.SameLine(); ImGui.SameLine();
var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing); PositionActionButton(i, startX, buttonWidth);
ImGui.SetCursorPosX(currentX);
} }
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) private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth)
{ {
_logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); _logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth);
@@ -543,7 +699,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private float CalculateNotificationHeight(LightlessNotification notification) private float CalculateNotificationHeight(LightlessNotification notification)
{ {
var contentWidth = _configService.Current.NotificationWidth - 35f; var contentWidth = CalculateContentWidth(_configService.Current.NotificationWidth);
var height = 12f; var height = 12f;
height += CalculateTitleHeight(notification, contentWidth); height += CalculateTitleHeight(notification, contentWidth);
@@ -561,7 +717,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
height += 12f; height += 12f;
} }
return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight); return Math.Clamp(height, _notificationMinHeight, _notificationMaxHeight);
} }
private float CalculateTitleHeight(LightlessNotification notification, float contentWidth) private float CalculateTitleHeight(LightlessNotification notification, float contentWidth)
@@ -590,6 +746,8 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
NotificationType.Error => UIColors.Get("DimRed"), NotificationType.Error => UIColors.Get("DimRed"),
NotificationType.PairRequest => UIColors.Get("LightlessBlue"), NotificationType.PairRequest => UIColors.Get("LightlessBlue"),
NotificationType.Download => UIColors.Get("LightlessGreen"), NotificationType.Download => UIColors.Get("LightlessGreen"),
NotificationType.Performance => UIColors.Get("LightlessOrange"),
_ => UIColors.Get("LightlessPurple") _ => UIColors.Get("LightlessPurple")
}; };
} }

View 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();
}
}

View 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);

View File

@@ -85,7 +85,7 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
{ {
var spacing = ImGui.GetStyle().ItemSpacing; 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)) if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{ {

View File

@@ -0,0 +1,12 @@
namespace LightlessSync.UI
{
public enum ProfileTags
{
SFW = 0,
NSFW = 1,
RP = 2,
ERP = 3,
Venues = 4,
Gpose = 5
}
}

View File

@@ -63,7 +63,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
private readonly NameplateService _nameplateService; private readonly NameplateService _nameplateService;
private readonly NameplateHandler _nameplateHandler; private readonly NameplateHandler _nameplateHandler;
private readonly NotificationService _lightlessNotificationService;
private (int, int, FileCacheEntity) _currentProgress; private (int, int, FileCacheEntity) _currentProgress;
private bool _deleteAccountPopupModalShown = false; private bool _deleteAccountPopupModalShown = false;
private bool _deleteFilesPopupModalShown = false; private bool _deleteFilesPopupModalShown = false;
@@ -107,8 +106,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
IpcManager ipcManager, CacheMonitor cacheMonitor, IpcManager ipcManager, CacheMonitor cacheMonitor,
DalamudUtilService dalamudUtilService, HttpClient httpClient, DalamudUtilService dalamudUtilService, HttpClient httpClient,
NameplateService nameplateService, NameplateService nameplateService,
NameplateHandler nameplateHandler, NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings",
NotificationService lightlessNotificationService) : base(logger, mediator, "Lightless Sync Settings",
performanceCollector) performanceCollector)
{ {
_configService = configService; _configService = configService;
@@ -130,7 +128,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared = uiShared; _uiShared = uiShared;
_nameplateService = nameplateService; _nameplateService = nameplateService;
_nameplateHandler = nameplateHandler; _nameplateHandler = nameplateHandler;
_lightlessNotificationService = lightlessNotificationService;
AllowClickthrough = false; AllowClickthrough = false;
AllowPinning = true; AllowPinning = true;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); _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), 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<OpenSettingsUiMessage>(this, (_) => Toggle());
Mediator.Subscribe<OpenLightfinderSettingsMessage>(this, (_) => Mediator.Subscribe<OpenLightfinderSettingsMessage>(this, (_) =>
{ {
@@ -591,6 +607,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter; bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter;
bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload; bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload;
int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes; int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes;
bool enableDirectDownloads = _configService.Current.EnableDirectDownloads;
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Global Download Speed Limit"); ImGui.TextUnformatted("Global Download Speed Limit");
@@ -622,6 +639,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("0 = No limit/infinite"); 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)) if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10))
{ {
_configService.Current.ParallelDownloads = maxParallelDownloads; _configService.Current.ParallelDownloads = maxParallelDownloads;
@@ -1203,16 +1227,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted($"Currently utilized local storage: Calculating..."); ImGui.TextUnformatted($"Currently utilized local storage: Calculating...");
ImGui.TextUnformatted( ImGui.TextUnformatted(
$"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}"); $"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}");
bool useFileCompactor = _configService.Current.UseCompactor; bool useFileCompactor = _configService.Current.UseCompactor;
bool isLinux = _dalamudUtilService.IsWine; if (!useFileCompactor)
if (!useFileCompactor && !isLinux)
{ {
UiSharedService.ColorTextWrapped( UiSharedService.ColorTextWrapped(
"Hint: To free up space when using Lightless consider enabling the File Compactor", "Hint: To free up space when using Lightless consider enabling the File Compactor",
UIColors.Get("LightlessYellow")); UIColors.Get("LightlessYellow"));
} }
if (isLinux || !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); if (!_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled();
if (ImGui.Checkbox("Use file compactor", ref useFileCompactor)) if (ImGui.Checkbox("Use file compactor", ref useFileCompactor))
{ {
_configService.Current.UseCompactor = useFileCompactor; _configService.Current.UseCompactor = useFileCompactor;
@@ -1257,10 +1281,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
UIColors.Get("LightlessYellow")); UIColors.Get("LightlessYellow"));
} }
if (isLinux || !_cacheMonitor.StorageisNTFS) if (!_cacheMonitor.StorageisNTFS)
{ {
ImGui.EndDisabled(); 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)); ImGuiHelpers.ScaledDummy(new Vector2(10, 10));
@@ -1990,7 +2024,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
("LightlessGreen", "Success Green", "Join buttons and success messages"), ("LightlessGreen", "Success Green", "Join buttons and success messages"),
("LightlessYellow", "Warning Yellow", "Warning colors"), ("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"), ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
("DimRed", "Error Red", "Error and offline colors") ("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)) if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles))
{ {
Mediator.Publish(new ClearProfileDataMessage()); Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesShow = showProfiles; _configService.Current.ProfilesShow = showProfiles;
_configService.Save(); _configService.Save();
} }
@@ -2321,7 +2355,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Unindent(); ImGui.Unindent();
if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles)) if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles))
{ {
Mediator.Publish(new ClearProfileDataMessage()); Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesAllowNsfw = showNsfwProfiles; _configService.Current.ProfilesAllowNsfw = showNsfwProfiles;
_configService.Save(); _configService.Save();
} }
@@ -2331,9 +2365,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop(); ImGui.TreePop();
} }
ImGui.Separator(); ImGui.Separator();
} }
private void DrawPerformance() private void DrawPerformance()
@@ -3091,22 +3123,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
UiSharedService.TooltipSeparator UiSharedService.TooltipSeparator
+ "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling"); + "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); ImGuiHelpers.ScaledDummy(5);
if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth)) if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth))
@@ -3493,69 +3509,178 @@ public class SettingsUi : WindowMediatorSubscriberBase
if (useLightlessNotifications) if (useLightlessNotifications)
{ {
// Lightless notification locations // Lightless notification locations
ImGui.Indent();
var lightlessLocations = GetLightlessNotificationLocations(); 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(); 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.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 else
{ {
@@ -3602,73 +3727,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
if (useLightlessNotifications) 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"))) if (_uiShared.MediumTreeNode("Basic Settings", UIColors.Get("LightlessPurple")))
{ {
int maxNotifications = _configService.Current.MaxSimultaneousNotifications; int maxNotifications = _configService.Current.MaxSimultaneousNotifications;
@@ -3768,10 +3826,28 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Spacing(); ImGui.Spacing();
ImGui.TextUnformatted("Position"); ImGui.TextUnformatted("Position");
int offsetY = _configService.Current.NotificationOffsetY; var currentCorner = _configService.Current.NotificationCorner;
if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 500)) 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(); _configService.Save();
} }
if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3781,12 +3857,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
} }
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (50)."); 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; 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(); _configService.Save();
} }
if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3802,9 +3878,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted("Animation Settings"); ImGui.TextUnformatted("Animation Settings");
float animSpeed = _configService.Current.NotificationAnimationSpeed; 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(); _configService.Save();
} }
if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3816,6 +3892,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (10)."); ImGui.SetTooltip("Right click to reset to default (10).");
_uiShared.DrawHelpText("How fast notifications slide in/out. Higher = faster."); _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.Spacing();
ImGui.TextUnformatted("Visual Effects"); ImGui.TextUnformatted("Visual Effects");
@@ -3888,9 +3979,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (20)."); ImGui.SetTooltip("Right click to reset to default (20).");
int pairRequestDuration = _configService.Current.PairRequestDurationSeconds; 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(); _configService.Save();
} }
if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3902,18 +3993,32 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (180)."); ImGui.SetTooltip("Right click to reset to default (180).");
int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds; 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(); _configService.Save();
} }
if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{ {
_configService.Current.DownloadNotificationDurationSeconds = 300; _configService.Current.DownloadNotificationDurationSeconds = 30;
_configService.Save(); _configService.Save();
} }
if (ImGui.IsItemHovered()) 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); _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop(); ImGui.TreePop();
@@ -3982,6 +4087,38 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop(); 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"))) if (_uiShared.MediumTreeNode("System Notifications", UIColors.Get("LightlessYellow")))
{ {
var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings; var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings;
@@ -3999,6 +4136,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
// Location descriptions removed - information is now inline with each setting // Location descriptions removed - information is now inline with each setting
} }
} }
@@ -4006,7 +4144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
return new[] 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[] return new[]
{ {
NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, NotificationLocation.LightlessUi, NotificationLocation.TextOverlay, NotificationLocation.Nowhere
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() private void DrawSoundTable()
{ {
var soundEffects = new[] var soundEffects = new[]
@@ -4068,7 +4215,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u), ("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u),
("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u), ("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u),
("Pair Request", 3, _configService.Current.PairRequestSoundId, _configService.Current.DisablePairRequestSound, 5u), ("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) 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); var currentIndex = Array.FindIndex(soundEffects, s => s.Item1 == currentSoundId);
if (currentIndex == -1) currentIndex = 1; if (currentIndex == -1) currentIndex = 1;
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); ImGui.SetNextItemWidth(-1);
if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex, if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex,
soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length)) soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length))
{ {
@@ -4098,7 +4245,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.CustomWarningSoundId = newSoundId; break; case 1: _configService.Current.CustomWarningSoundId = newSoundId; break;
case 2: _configService.Current.CustomErrorSoundId = newSoundId; break; case 2: _configService.Current.CustomErrorSoundId = newSoundId; break;
case 3: _configService.Current.PairRequestSoundId = 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(); _configService.Save();
@@ -4152,7 +4299,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.DisableWarningSound = newDisabled; break; case 1: _configService.Current.DisableWarningSound = newDisabled; break;
case 2: _configService.Current.DisableErrorSound = newDisabled; break; case 2: _configService.Current.DisableErrorSound = newDisabled; break;
case 3: _configService.Current.DisablePairRequestSound = 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(); _configService.Save();
} }
@@ -4178,7 +4325,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break; case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break;
case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break; case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break;
case 3: _configService.Current.PairRequestSoundId = 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(); _configService.Save();
} }

View File

@@ -51,7 +51,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
{ {
var spacing = ImGui.GetStyle().ItemSpacing; 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)) if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{ {

View File

@@ -1,17 +1,26 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI.Handlers;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Numerics;
namespace LightlessSync.UI; namespace LightlessSync.UI;
@@ -22,29 +31,51 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly bool _isOwner = false; private readonly bool _isOwner = false;
private readonly List<string> _oneTimeInvites = []; private readonly List<string> _oneTimeInvites = [];
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly FileDialogManager _fileDialogManager;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private List<BannedGroupUserDto> _bannedUsers = []; 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 int _multiInvites;
private string _newPassword; private string _newPassword;
private bool _pwChangeSuccess; private bool _pwChangeSuccess;
private Task<int>? _pruneTestTask; private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask; private Task<int>? _pruneTask;
private int _pruneDays = 14; private int _pruneDays = 14;
private List<int> _selectedTags = [];
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController, 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) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{ {
GroupFullInfo = groupFullInfo; GroupFullInfo = groupFullInfo;
_apiController = apiController; _apiController = apiController;
_uiSharedService = uiSharedService; _uiSharedService = uiSharedService;
_pairManager = pairManager; _pairManager = pairManager;
_lightlessProfileManager = lightlessProfileManager;
_fileDialogManager = fileDialogManager;
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
_newPassword = string.Empty; _newPassword = string.Empty;
_multiInvites = 30; _multiInvites = 30;
_pwChangeSuccess = true; _pwChangeSuccess = true;
IsOpen = 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() SizeConstraints = new WindowSizeConstraints()
{ {
MinimumSize = new(700, 500), MinimumSize = new(700, 500),
@@ -58,10 +89,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{ {
if (!_isModerator && !_isOwner) return; if (!_isModerator && !_isOwner) return;
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group]; 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()) using (_uiSharedService.UidFont.Push())
_uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue")); _uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue"));
@@ -77,6 +111,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
DrawManagement(); DrawManagement();
DrawPermission(perm); DrawPermission(perm);
DrawProfile();
} }
} }
@@ -176,6 +212,184 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ownerTab.Dispose(); 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() private void DrawManagement()
{ {
@@ -192,7 +406,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{ {
var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp;
if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY; 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) if (table)
{ {
ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4); ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4);
@@ -474,7 +688,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
} }
mgmtTab.Dispose(); mgmtTab.Dispose();
} }
private void DrawInvites(GroupPermissions perm) private void DrawInvites(GroupPermissions perm)
@@ -521,9 +734,37 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
} }
inviteTab.Dispose(); 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() public override void OnClose()
{ {
Mediator.Publish(new RemoveWindowMessage(this)); Mediator.Publish(new RemoveWindowMessage(this));
_pfpTextureWrap?.Dispose();
} }
} }

View File

@@ -88,7 +88,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
ImGuiHelpers.ScaledDummy(0.5f); ImGuiHelpers.ScaledDummy(0.5f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); 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))) if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
{ {
@@ -288,8 +288,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
return; return;
} }
var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal);
if (updatedList != null) if (updatedList != null)
{ {
var previousGid = GetSelectedGid(); var previousGid = GetSelectedGid();

View File

@@ -196,82 +196,6 @@ public class TopTabMenu
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); 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); DrawIncomingPairRequests(availableWidth);
ImGui.Separator(); ImGui.Separator();

View File

@@ -11,21 +11,17 @@ namespace LightlessSync.UI
{ "LightlessPurple", "#ad8af5" }, { "LightlessPurple", "#ad8af5" },
{ "LightlessPurpleActive", "#be9eff" }, { "LightlessPurpleActive", "#be9eff" },
{ "LightlessPurpleDefault", "#9375d1" }, { "LightlessPurpleDefault", "#9375d1" },
{ "ButtonDefault", "#323232" }, { "ButtonDefault", "#323232" },
{ "FullBlack", "#000000" }, { "FullBlack", "#000000" },
{ "LightlessBlue", "#a6c2ff" }, { "LightlessBlue", "#a6c2ff" },
{ "LightlessYellow", "#ffe97a" }, { "LightlessYellow", "#ffe97a" },
{ "LightlessYellow2", "#cfbd63" },
{ "LightlessGreen", "#7cd68a" }, { "LightlessGreen", "#7cd68a" },
{ "LightlessOrange", "#ffb366" },
{ "PairBlue", "#88a2db" }, { "PairBlue", "#88a2db" },
{ "DimRed", "#d44444" }, { "DimRed", "#d44444" },
{ "LightlessAdminText", "#ffd663" }, { "LightlessAdminText", "#ffd663" },
{ "LightlessAdminGlow", "#b09343" }, { "LightlessAdminGlow", "#b09343" },
{ "LightlessModeratorText", "#94ffda" }, { "LightlessModeratorText", "#94ffda" },
{ "LightlessModeratorGlow", "#599c84" },
{ "Lightfinder", "#ad8af5" }, { "Lightfinder", "#ad8af5" },
{ "LightfinderEdge", "#000000" }, { "LightfinderEdge", "#000000" },

View 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
}
}
}

View File

@@ -5,16 +5,39 @@ namespace LightlessSync.Utils;
public static class Crypto 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 #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 Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new(); private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
public static string GetFileHash(this string filePath) public static string GetFileHash(this string filePath)
{ {
using SHA1CryptoServiceProvider cryptoProvider = new(); using SHA1 sha1 = SHA1.Create();
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal); 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) public static string GetHash256(this (string, ushort) playerToHash)

View 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");
}
}

View File

@@ -7,6 +7,7 @@ using Dalamud.Interface.Utility;
using Lumina.Text; using Lumina.Text;
using System; using System;
using System.Numerics; using System.Numerics;
using System.Threading;
using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString; using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString;
using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder; using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder;
using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder; using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder;
@@ -15,6 +16,9 @@ namespace LightlessSync.Utils;
public static class SeStringUtils public static class SeStringUtils
{ {
private static int _seStringHitboxCounter;
private static int _iconHitboxCounter;
public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor) public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor)
{ {
var b = new DalamudSeStringBuilder(); var b = new DalamudSeStringBuilder();
@@ -119,7 +123,7 @@ public static class SeStringUtils
ImGui.Dummy(new Vector2(0f, textSize.Y)); 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(); var drawList = ImGui.GetWindowDrawList();
@@ -137,12 +141,28 @@ public static class SeStringUtils
var textSize = ImGui.CalcTextSize(seString.TextValue); var textSize = ImGui.CalcTextSize(seString.TextValue);
ImGui.SetCursorScreenPos(position); 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; 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(); var drawList = ImGui.GetWindowDrawList();
@@ -158,7 +178,23 @@ public static class SeStringUtils
var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams);
ImGui.SetCursorScreenPos(position); 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; return drawResult.Size;
} }

View File

@@ -5,12 +5,18 @@ using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes; using LightlessSync.API.Routes;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.IO;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
namespace LightlessSync.WebAPI.Files; namespace LightlessSync.WebAPI.Files;
@@ -20,17 +26,27 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private readonly FileCompactor _fileCompactor; private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager; private readonly FileCacheManager _fileDbManager;
private readonly FileTransferOrchestrator _orchestrator; private readonly FileTransferOrchestrator _orchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly LightlessConfigService _configService;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams; 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, public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
FileTransferOrchestrator orchestrator, 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); _downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_orchestrator = orchestrator; _orchestrator = orchestrator;
_fileDbManager = fileCacheManager; _fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
_activeDownloadStreams = new(); _activeDownloadStreams = new();
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) => Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
{ {
@@ -50,6 +66,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public bool IsDownloading => CurrentDownloads.Any(); public bool IsDownloading => CurrentDownloads.Any();
private bool ShouldUseDirectDownloads()
{
return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads;
}
public static void MungeBuffer(Span<byte> buffer) public static void MungeBuffer(Span<byte> buffer)
{ {
for (int i = 0; i < buffer.Length; ++i) 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); 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; const int maxRetries = 3;
int retryCount = 0; int retryCount = 0;
TimeSpan retryDelay = TimeSpan.FromSeconds(2); TimeSpan retryDelay = TimeSpan.FromSeconds(2);
HttpResponseMessage? response = null;
HttpResponseMessage response = null!;
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
while (true) while (true)
{ {
try try
{ {
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl} for request {id}", retryCount + 1, requestUrl, requestId); Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
break; break;
} }
catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null) catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null)
{ {
response?.Dispose();
retryCount++; retryCount++;
Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries); Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
if (retryCount >= maxRetries || ct.IsCancellationRequested) 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; 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) catch (HttpRequestException ex)
{ {
response?.Dispose();
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode); Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized) if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
@@ -199,39 +248,77 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
throw; throw;
} }
} }
ThrottledStream? stream = null; ThrottledStream? stream = null;
FileStream? fileStream = null; FileStream? fileStream = null;
try try
{ {
fileStream = File.Create(tempPath); fileStream = File.Create(destinationFilename);
await using (fileStream.ConfigureAwait(false)) 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 buffer = new byte[bufferSize];
var bytesRead = 0;
var limit = _orchestrator.DownloadLimitPerSlot(); 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); stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
_activeDownloadStreams.TryAdd(stream, 0); _activeDownloadStreams.TryAdd(stream, 0);
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0) while (true)
{ {
ct.ThrowIfCancellationRequested(); 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); await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
progress.Report(bytesRead); 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) catch (OperationCanceledException)
{ {
throw; throw;
@@ -242,14 +329,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{ {
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 catch
{ {
// Ignore errors during cleanup // ignore cleanup errors
} }
throw; throw;
} }
@@ -260,6 +347,134 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_activeDownloadStreams.TryRemove(stream, out _); _activeDownloadStreams.TryRemove(stream, out _);
await stream.DisposeAsync().ConfigureAwait(false); 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) 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, DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadGroup.Sum(c => c.Total), TotalBytes = directDownload.Total,
TotalFiles = 1, TotalFiles = 1,
TransferredBytes = 0, TransferredBytes = 0,
TransferredFiles = 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)); 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, CancellationToken = ct,
}, },
async (fileGroup, token) => async (fileGroup, token) =>
{ {
// let server predownload files
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri), var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false); 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, 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; downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue; downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
Progress<long> progress = new((bytesDownloaded) => var progress = CreateInlineProgress((bytesDownloaded) =>
{ {
try try
{ {
@@ -371,7 +632,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
} }
catch (OperationCanceledException) 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) catch (Exception ex)
{ {
@@ -382,72 +643,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
return; return;
} }
FileStream? fileBlockStream = null;
try try
{ {
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
if (!File.Exists(blockFile)) if (!File.Exists(blockFile))
{ {
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
return; return;
} }
fileBlockStream = File.OpenRead(blockFile); await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false);
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);
} }
finally finally
{ {
_orchestrator.ReleaseDownloadSlot(); _orchestrator.ReleaseDownloadSlot();
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
File.Delete(blockFile); 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(); ClearDownload();
} }
@@ -554,4 +910,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator.ClearDownloadRequest(requestId); _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);
}
}
} }

View File

@@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
} }
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri, 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); 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); using var requestMessage = new HttpRequestMessage(method, uri);
if (content is not ByteArrayContent) if (content is not ByteArrayContent)
requestMessage.Content = JsonContent.Create(content); requestMessage.Content = JsonContent.Create(content);
else else
requestMessage.Content = content as ByteArrayContent; 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); using var requestMessage = new HttpRequestMessage(method, uri);
requestMessage.Content = content; 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) public async Task WaitForDownloadSlotAsync(CancellationToken token)
@@ -144,10 +147,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
} }
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage, 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); if (withToken)
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); {
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) if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
{ {

View File

@@ -18,6 +18,7 @@ public class DownloadFileTransfer : FileTransfer
} }
get => Dto.Size; get => Dto.Size;
} }
public string? DirectDownloadUrl => ((DownloadFileDto)TransferDto).CDNDownloadUrl;
public long TotalRaw => Dto.RawSize; public long TotalRaw => Dto.RawSize;
private DownloadFileDto Dto => (DownloadFileDto)TransferDto; private DownloadFileDto Dto => (DownloadFileDto)TransferDto;

View File

@@ -84,7 +84,7 @@ public partial class ApiController
public async Task<UserProfileDto> UserGetProfile(UserDto dto) 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); return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
} }

View File

@@ -107,17 +107,17 @@ public partial class ApiController
} }
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto) public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto)
{ {
if (dto == null) Logger.LogDebug("Client_ReceiveBroadcastPairRequest: {dto}", dto);
if (dto is null)
{
return Task.CompletedTask; return Task.CompletedTask;
}
var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty); ExecuteSafely(() =>
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName; {
Mediator.Publish(new PairRequestReceivedMessage(dto.myHashedCid, dto.message ?? string.Empty));
_lightlessNotificationService.ShowPairRequestNotification( });
senderName,
request.HashedCid,
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName),
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid));
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -195,7 +195,14 @@ public partial class ApiController
public Task Client_UserUpdateProfile(UserDto dto) public Task Client_UserUpdateProfile(UserDto dto)
{ {
Logger.LogDebug("Client_UserUpdateProfile: {dto}", 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; return Task.CompletedTask;
} }
@@ -380,6 +387,12 @@ public partial class ApiController
_lightlessHub!.On(nameof(Client_UserUpdateProfile), act); _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) public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
{ {
if (_initialized) return; if (_initialized) return;

View File

@@ -115,6 +115,18 @@ public partial class ApiController
CheckConnection(); CheckConnection();
return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false); 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() public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{ {
@@ -139,7 +151,6 @@ public partial class ApiController
.ConfigureAwait(false); .ConfigureAwait(false);
} }
private void CheckConnection() private void CheckConnection()
{ {
if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected"); if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected");

View File

@@ -2,6 +2,7 @@ using Dalamud.Utility;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto; using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.API.SignalR; using LightlessSync.API.SignalR;
@@ -32,7 +33,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
private readonly ServerConfigurationManager _serverManager; private readonly ServerConfigurationManager _serverManager;
private readonly TokenProvider _tokenProvider; private readonly TokenProvider _tokenProvider;
private readonly LightlessConfigService _lightlessConfigService; private readonly LightlessConfigService _lightlessConfigService;
private readonly NotificationService _lightlessNotificationService;
private CancellationTokenSource _connectionCancellationTokenSource; private CancellationTokenSource _connectionCancellationTokenSource;
private ConnectionDto? _connectionDto; private ConnectionDto? _connectionDto;
private bool _doNotNotifyOnNextInfo = false; private bool _doNotNotifyOnNextInfo = false;
@@ -54,7 +54,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
_serverManager = serverManager; _serverManager = serverManager;
_tokenProvider = tokenProvider; _tokenProvider = tokenProvider;
_lightlessConfigService = lightlessConfigService; _lightlessConfigService = lightlessConfigService;
_lightlessNotificationService = lightlessNotificationService;
_connectionCancellationTokenSource = new CancellationTokenSource(); _connectionCancellationTokenSource = new CancellationTokenSource();
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn()); Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
@@ -608,17 +607,42 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
ServerState = state; ServerState = state;
} }
public Task Client_GroupSendProfile(GroupProfileDto groupInfo) public Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<GroupProfileDto> GroupGetProfile(GroupDto dto) public Task UpdateChatPresence(ChatPresenceUpdateDto presence)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task GroupSetProfile(GroupProfileDto dto) public Task Client_ChatReceive(ChatMessageDto message)
{
throw new NotImplementedException();
}
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(); throw new NotImplementedException();
} }

View File

@@ -70,13 +70,6 @@ public class HubFactory : MediatorSubscriberBase
_ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling _ => 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); Logger.LogDebug("Building new HubConnection using transport {transport}", transportType);
_instance = new HubConnectionBuilder() _instance = new HubConnectionBuilder()

View File

@@ -133,6 +133,12 @@
"Microsoft.IdentityModel.Tokens": "8.7.0" "Microsoft.IdentityModel.Tokens": "8.7.0"
} }
}, },
"YamlDotNet": {
"type": "Direct",
"requested": "[16.3.0, )",
"resolved": "16.3.0",
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
},
"K4os.Compression.LZ4": { "K4os.Compression.LZ4": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.3.8", "resolved": "1.3.8",