Compare commits
236 Commits
1.12.0-Dev
...
1.12.3.1-D
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68595d7b1 | ||
|
|
becba8e221 | ||
|
|
9c794137c1 | ||
|
|
4a256f7807 | ||
|
|
25756561b9 | ||
|
|
e8c546c128 | ||
| d4ba1cf437 | |||
| e0d1f98c70 | |||
|
|
1862689b1b | ||
| 325dc8947d | |||
|
|
95e7f2daa7 | ||
|
|
41a303dc91 | ||
|
|
25b03aea15 | ||
|
|
b6564156f0 | ||
|
|
f89ce900c7 | ||
| 299abc21ee | |||
|
|
c02a8ed2ee | ||
|
|
8692e877cf | ||
|
|
7de72471bb | ||
|
|
d7182e9d57 | ||
| 2b02de731a | |||
|
|
e9082ab8d0 | ||
| 2a06a11cbc | |||
|
|
557121a9b7 | ||
| b22140a8d4 | |||
| 4db468a480 | |||
| 8d8f8d20cd | |||
| 3722b79615 | |||
| cf97e7e800 | |||
|
|
1d672d2552 | ||
|
|
35636f27f6 | ||
|
|
1b686e45dc | ||
|
|
b6aa2bebb1 | ||
|
|
cfc9f60176 | ||
|
|
d4dca455ba | ||
| 76c2777f00 | |||
|
|
0af2a6134b | ||
|
|
6e3c60f627 | ||
|
|
5feb74c1c0 | ||
|
|
c1770528f3 | ||
|
|
bf139c128b | ||
|
|
b3cc41382f | ||
|
|
7c4d0fd5e9 | ||
|
|
c37e3badf1 | ||
| f4478f653a | |||
|
|
3f85852618 | ||
|
|
3e626c5e47 | ||
|
|
9a846a37d4 | ||
|
|
177534d78b | ||
|
|
de75b90703 | ||
|
|
c16891021c | ||
| d19d1c0a3a | |||
|
|
cabc4ec0fe | ||
|
|
8bccdc5ef1 | ||
|
|
ce5f8a43a2 | ||
|
|
437731749f | ||
|
|
55e78e088a | ||
| 5abc297a94 | |||
|
|
8aad714918 | ||
| b5bdededae | |||
|
|
487156e4f9 | ||
| 90d8f691d2 | |||
|
|
764bb8bae2 | ||
| 5d2c58bf3e | |||
|
|
6bb00c50d8 | ||
|
|
1a89c2caee | ||
|
|
1e97f27cb8 | ||
|
|
7aadbcec10 | ||
|
|
a32ac02c6d | ||
| f48698373b | |||
| ee20b6fa5f | |||
| f11741225b | |||
|
|
147baa4c1b | ||
|
|
4f5ef8ff4b | ||
|
|
fae6d31792 | ||
|
|
b4dd0ee0e1 | ||
| f5458c7f97 | |||
| 923f118a47 | |||
|
|
0cb71e5444 | ||
|
|
268fd471fe | ||
|
|
d517a21f5d | ||
| cac94374d9 | |||
|
|
b513e0555b | ||
|
|
de8c9cf035 | ||
|
|
7f8872cbe0 | ||
|
|
5626a34755 | ||
|
|
a98afdda01 | ||
| 4373092d44 | |||
| 7b5c61371e | |||
| 9bd997f699 | |||
| e2511a5c1f | |||
|
|
e8760a8937 | ||
|
|
7d4e097be8 | ||
|
|
2cba1ccfe0 | ||
| 06921e1dd1 | |||
|
|
68ba5f4b06 | ||
|
|
217c160ec7 | ||
|
|
ff010fa1f8 | ||
| f7145339b3 | |||
|
|
aa2b828386 | ||
|
|
547db3a76b | ||
|
|
4ac8b24524 | ||
|
|
d72cc207e1 | ||
|
|
477f5aa6e7 | ||
|
|
edb7232b17 | ||
|
|
44177ab7bd | ||
|
|
47b7ecd521 | ||
|
|
8a3902ec2b | ||
|
|
ea8f8e3895 | ||
|
|
f1af6601cc | ||
|
|
2d094404df | ||
|
|
8fdff1eb18 | ||
|
|
9170b5205c | ||
|
|
dccd2cdc36 | ||
|
|
6d01d47c2f | ||
|
|
280c80d89f | ||
|
|
92b8d4a1cd | ||
|
|
f77d109d00 | ||
|
|
823dd39a9b | ||
|
|
d5c12e81c3 | ||
|
|
04c00af92e | ||
|
|
a66a43dda8 | ||
| 77ff8ae372 | |||
|
|
011cf7951b | ||
|
|
7d480b9e2c | ||
|
|
cf27a67296 | ||
|
|
d6a4595bb8 | ||
|
|
f202818b55 | ||
|
|
3f2e4d6640 | ||
|
|
90b483e4ea | ||
| 434c7d5f4a | |||
|
|
a4eb840589 | ||
|
|
e80806ef9d | ||
|
|
c447c33b7a | ||
|
|
bcb524df52 | ||
| b64bb66119 | |||
|
|
118edb9dea | ||
|
|
b43ceb9f7e | ||
|
|
6c0d00dc39 | ||
|
|
be847c16b8 | ||
| 02a680f8cc | |||
|
|
dae8127ac8 | ||
|
|
0635caab65 | ||
| 1530ac3911 | |||
| 3f80467180 | |||
|
|
4b4e587a89 | ||
|
|
02c3846031 | ||
|
|
a8a01b3034 | ||
| 50a5046c96 | |||
|
|
c0b8e15380 | ||
| e6735be594 | |||
|
|
fe419336d7 | ||
|
|
ffbeeba929 | ||
|
|
a7475a7007 | ||
|
|
3936cbd439 | ||
|
|
ba16963b66 | ||
| 6467a3e73b | |||
|
|
bb779904f7 | ||
|
|
59d0e8ee37 | ||
|
|
a441bbfcc8 | ||
|
|
c545ccea52 | ||
|
|
c32d9cadff | ||
|
|
d7c9df54cb | ||
| 37ec0961d9 | |||
| 9736c5090d | |||
|
|
4f3ab604db | ||
|
|
6a0f8c507c | ||
|
|
e13fde3d43 | ||
| 7b806ab660 | |||
|
|
387e5ad515 | ||
|
|
70c296a16b | ||
|
|
2a9b5812ed | ||
|
|
9b04976aa6 | ||
|
|
144ac166fb | ||
|
|
98c3a2c7f8 | ||
|
|
b06ffb3341 | ||
| e9461efe11 | |||
| 1f1afdec24 | |||
| d428a436e7 | |||
|
|
ad29fa7b69 | ||
|
|
23c56505ac | ||
|
|
58850f4530 | ||
|
|
f5339dc1d2 | ||
|
|
85ecea6391 | ||
|
|
cd817487e4 | ||
|
|
f50b622f0a | ||
|
|
d295f3e22d | ||
|
|
0dfa667ed3 | ||
|
|
2b118df892 | ||
|
|
f01229a97f | ||
|
|
3fdc9dd958 | ||
|
|
86107acf12 | ||
|
|
27e7fb7ed9 | ||
|
|
17f4ddad89 | ||
| a29e155cec | |||
|
|
1488704db4 | ||
|
|
46db5c87e0 | ||
| a772ee4705 | |||
|
|
1d88c04235 | ||
|
|
a7378652c4 | ||
| 61267d1b03 | |||
|
|
9b6d00570e | ||
|
|
83e4555e4b | ||
|
|
090b81c989 | ||
|
|
ca70c622bc | ||
|
|
49e5fb9d8d | ||
| a2bb1d7336 | |||
| 4f50028517 | |||
| 9f87a6a8fc | |||
|
|
4a391f2392 | ||
| a7c4b8f356 | |||
| c35650438c | |||
| b87185bc33 | |||
| 67da22fe9f | |||
|
|
15798e6753 | ||
| ca68e63c7d | |||
| eb10a27c6e | |||
| 39784a1fea | |||
|
|
fec2e4d380 | ||
|
|
afc3b4534c | ||
|
|
98b9cc7fe7 | ||
|
|
fd26d776a5 | ||
|
|
fdfd5722c7 | ||
|
|
19e42d34ff | ||
|
|
173e0aa7ae | ||
|
|
55d979b7c0 | ||
|
|
f31a139a3e | ||
| c82c633513 | |||
|
|
f6ea1eddc0 | ||
|
|
4ca3b6da48 | ||
| e75dd86fdb | |||
| 716d7a54d9 | |||
| 701ccaffe4 | |||
| ee8d05ca7a | |||
| fd5522b90a | |||
| 7672f147f5 |
@@ -2,7 +2,7 @@ name: Tag and Release Lightless
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ master, dev ]
|
||||
|
||||
env:
|
||||
PLUGIN_NAME: LightlessSync
|
||||
@@ -41,9 +41,9 @@ jobs:
|
||||
|
||||
- name: Get version
|
||||
id: package_version
|
||||
uses: KageKirin/get-csproj-version@v0
|
||||
with:
|
||||
file: LightlessSync/LightlessSync.csproj
|
||||
run: |
|
||||
version=$(grep -oPm1 "(?<=<Version>)[^<]+" LightlessSync/LightlessSync.csproj)
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Display version
|
||||
run: |
|
||||
@@ -62,7 +62,8 @@ jobs:
|
||||
mkdir -p output
|
||||
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
|
||||
|
||||
- name: Create Git tag if not exists
|
||||
- name: Create Git tag if not exists (master)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
tag="${{ steps.package_version.outputs.version }}"
|
||||
git fetch --tags
|
||||
@@ -76,7 +77,23 @@ jobs:
|
||||
echo "Tag $tag already exists. Skipping tag creation."
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
- name: Create Git tag if not exists (dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
tag="${{ steps.package_version.outputs.version }}-Dev"
|
||||
git fetch --tags
|
||||
if ! git tag -l "$tag" | grep -q "$tag"; then
|
||||
echo "Tag $tag does not exist. Creating and pushing..."
|
||||
git config user.name "GitHub Action"
|
||||
git config user.email "action@github.com"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
else
|
||||
echo "Tag $tag already exists. Skipping tag creation."
|
||||
fi
|
||||
|
||||
- name: Create Release (master)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
id: create_release
|
||||
run: |
|
||||
echo "=== Searching for existing release ${{ steps.package_version.outputs.version }}==="
|
||||
@@ -104,15 +121,60 @@ jobs:
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
)
|
||||
|
||||
echo "API response: $response"
|
||||
release_id=$(echo "$response" | jq -r .id)
|
||||
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||
echo "release_id=$release_id"
|
||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
|
||||
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload Assets to release
|
||||
- name: Create Release (dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
id: create_release
|
||||
run: |
|
||||
version="${{ steps.package_version.outputs.version }}-Dev"
|
||||
echo "=== Searching for existing release $version==="
|
||||
release_id=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/$version" | jq -r .id)
|
||||
if [ "$release_id" != "null" ]; then
|
||||
echo "=== Deleting existing release $version==="
|
||||
curl -X DELETE -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$release_id"
|
||||
fi
|
||||
echo "=== Creating new release $version==="
|
||||
response=$(
|
||||
curl --fail-with-body -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-d '{
|
||||
"tag_name": "'"$version"'",
|
||||
"name": "'"$version"'",
|
||||
"draft": false,
|
||||
"prerelease": false
|
||||
}' \
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
)
|
||||
echo "API response: $response"
|
||||
release_id=$(echo "$response" | jq -r .id)
|
||||
echo "release_id=$release_id"
|
||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
|
||||
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
|
||||
|
||||
- name: Check asset exists
|
||||
run: |
|
||||
if [ ! -f output/LightlessClient.zip ]; then
|
||||
echo "output/LightlessClient.zip does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Assets to release
|
||||
env:
|
||||
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||
run: |
|
||||
echo "Uploading to release ID: $RELEASE_ID"
|
||||
curl --fail-with-body -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-F "attachment=@output/LightlessClient.zip" \
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/${{ steps.create_release.outputs.release_id }}/assets"
|
||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
|
||||
|
||||
- name: Clone plugin hosting repo
|
||||
run: |
|
||||
@@ -122,7 +184,8 @@ jobs:
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: 0
|
||||
|
||||
- name: Update plogonmaster.json with version
|
||||
- name: Update plogonmaster.json with version (master)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
VERSION: ${{ steps.package_version.outputs.version }}
|
||||
run: |
|
||||
@@ -159,7 +222,6 @@ jobs:
|
||||
.DalamudApiLevel = $dalamudApiLevel
|
||||
| .AssemblyVersion = $version
|
||||
| .DownloadLinkInstall = $downloadUrl
|
||||
| .DownloadLinkTesting = $downloadUrl
|
||||
| .DownloadLinkUpdate = $downloadUrl
|
||||
else
|
||||
.
|
||||
@@ -172,6 +234,47 @@ jobs:
|
||||
# Output the content of the file
|
||||
cat "$repoJsonPath"
|
||||
|
||||
- name: Update plogonmaster.json with version (dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
VERSION: ${{ steps.package_version.outputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
pluginJsonPath="${PLUGIN_NAME}/bin/x64/Release/${PLUGIN_NAME}.json"
|
||||
repoJsonPath="LightlessSyncRepo/LightlessSync/plogonmaster.json"
|
||||
assemblyVersion="${VERSION}"
|
||||
version="${VERSION}-Dev"
|
||||
downloadUrl="https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessClient/releases/download/$version/LightlessClient.zip"
|
||||
pluginJson=$(cat "$pluginJsonPath")
|
||||
internalName=$(jq -r '.InternalName' <<< "$pluginJson")
|
||||
dalamudApiLevel=$(jq -r '.DalamudApiLevel' <<< "$pluginJson")
|
||||
repoJsonRaw=$(cat "$repoJsonPath")
|
||||
if echo "$repoJsonRaw" | jq 'type' | grep -q '"array"'; then
|
||||
repoJson="$repoJsonRaw"
|
||||
else
|
||||
repoJson="[$repoJsonRaw]"
|
||||
fi
|
||||
updatedRepoJson=$(jq \
|
||||
--arg internalName "$internalName" \
|
||||
--arg dalamudApiLevel "$dalamudApiLevel" \
|
||||
--arg assemblyVersion "$assemblyVersion" \
|
||||
--arg version "$version" \
|
||||
--arg downloadUrl "$downloadUrl" \
|
||||
'
|
||||
map(
|
||||
if .InternalName == $internalName
|
||||
then
|
||||
.DalamudApiLevel = $dalamudApiLevel
|
||||
| .TestingAssemblyVersion = $assemblyVersion
|
||||
| .DownloadLinkTesting = $downloadUrl
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' <<< "$repoJson")
|
||||
echo "$updatedRepoJson" > "$repoJsonPath"
|
||||
cat "$repoJsonPath"
|
||||
|
||||
- name: Commit and push to LightlessSync
|
||||
run: |
|
||||
cd LightlessSyncRepo/LightlessSync
|
||||
|
||||
140
.github/workflows/lightless-tag-and-release.yml
vendored
140
.github/workflows/lightless-tag-and-release.yml
vendored
@@ -1,140 +0,0 @@
|
||||
name: Tag and Release Lightless
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
env:
|
||||
PLUGIN_NAME: LightlessSync
|
||||
DOTNET_VERSION: 9.x
|
||||
|
||||
jobs:
|
||||
tag-and-release:
|
||||
runs-on: windows-2022
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Lightless
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET 9 SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.x
|
||||
|
||||
- name: Download Dalamud
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip
|
||||
Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev"
|
||||
|
||||
- name: Lets Build Lightless!
|
||||
run: |
|
||||
dotnet restore
|
||||
dotnet build --configuration Release --no-restore
|
||||
dotnet publish --configuration Release --no-build
|
||||
|
||||
- name: Get version
|
||||
id: package_version
|
||||
uses: KageKirin/get-csproj-version@v0
|
||||
with:
|
||||
file: LightlessSync/LightlessSync.csproj
|
||||
|
||||
- name: Display version
|
||||
run: |
|
||||
echo "Version: ${{ steps.package_version.outputs.version }}"
|
||||
|
||||
- name: Prepare Lightless Client
|
||||
run: |
|
||||
$publishPath = "${{ env.PLUGIN_NAME }}/bin/x64/Release/publish"
|
||||
if (Test-Path $publishPath) {
|
||||
Remove-Item -Recurse -Force $publishPath
|
||||
Write-Host "Removed $publishPath"
|
||||
} else {
|
||||
Write-Host "$publishPath does not exist, nothing to remove."
|
||||
}
|
||||
mkdir output
|
||||
Compress-Archive -Path ${{ env.PLUGIN_NAME }}/bin/x64/Release/* -DestinationPath output/LightlessClient.zip
|
||||
|
||||
- name: Create Git tag if not exists
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ steps.package_version.outputs.version }}"
|
||||
git fetch --tags
|
||||
if (-not (git tag -l $tag)) {
|
||||
Write-Host "Tag $tag does not exist. Creating and pushing..."
|
||||
git config user.name "GitHub Action"
|
||||
git config user.email "action@github.com"
|
||||
git tag $tag
|
||||
git push origin $tag
|
||||
} else {
|
||||
Write-Host "Tag $tag already exists. Skipping tag creation."
|
||||
}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.package_version.outputs.version }}
|
||||
name: ${{ steps.package_version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: output/LightlessClient.zip
|
||||
|
||||
- name: Clone plugin hosting repo
|
||||
run: |
|
||||
mkdir LightlessSyncRepo
|
||||
cd LightlessSyncRepo
|
||||
git clone https://github.com/${{ github.repository_owner }}/LightlessSync.git
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: 0
|
||||
|
||||
- name: Update plogonmaster.json with version
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ steps.package_version.outputs.version }}
|
||||
run: |
|
||||
$pluginJsonPath = "${{ env.PLUGIN_NAME }}/bin/x64/Release/${{ env.PLUGIN_NAME }}.json"
|
||||
$pluginJson = Get-Content $pluginJsonPath | ConvertFrom-Json
|
||||
$repoJsonPath = "LightlessSyncRepo/LightlessSync/plogonmaster.json"
|
||||
$repoJsonRaw = Get-Content $repoJsonPath -Raw
|
||||
$repoJson = $repoJsonRaw | ConvertFrom-Json
|
||||
$version = $env:VERSION
|
||||
$downloadUrl = "https://github.com/${{ github.repository_owner }}/LightlessClient/releases/download/$version/LightlessClient.zip"
|
||||
|
||||
if (-not ($repoJson -is [System.Collections.IEnumerable])) {
|
||||
$repoJson = @($repoJson)
|
||||
}
|
||||
|
||||
foreach ($plugin in $repoJson) {
|
||||
if ($plugin.InternalName -eq $pluginJson.InternalName) {
|
||||
$plugin.DalamudApiLevel = $pluginJson.DalamudApiLevel
|
||||
$plugin.AssemblyVersion = $version
|
||||
$plugin.DownloadLinkInstall = $downloadUrl
|
||||
$plugin.DownloadLinkTesting = $downloadUrl
|
||||
$plugin.DownloadLinkUpdate = $downloadUrl
|
||||
}
|
||||
}
|
||||
|
||||
$repoJson | ConvertTo-Json -Depth 100 | Set-Content $repoJsonPath
|
||||
|
||||
# Convert to JSON and force array brackets if necessary
|
||||
$repoJsonString = $repoJson | ConvertTo-Json -Depth 100
|
||||
|
||||
# If the output is not an array, wrap it manually
|
||||
if ($repoJsonString.Trim().StartsWith('{')) {
|
||||
$repoJsonString = "[$repoJsonString]"
|
||||
}
|
||||
|
||||
$repoJsonString | Set-Content $repoJsonPath
|
||||
|
||||
- name: Commit and push to LightlessSync
|
||||
run: |
|
||||
cd LightlessSyncRepo/LightlessSync
|
||||
git config user.name "github-actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
git add .
|
||||
git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
|
||||
git push https://x-access-token:${{ secrets.LIGHTLESS_TOKEN }}@github.com/${{ github.repository_owner }}/LightlessSync.git HEAD:main
|
||||
Submodule LightlessAPI updated: a337481243...0170ac377d
210
LightlessSync/Changelog/changelog.yaml
Normal file
210
LightlessSync/Changelog/changelog.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
tagline: "Lightless Sync v1.12.4"
|
||||
subline: "Bugfixes and various improvements across Lightless"
|
||||
changelog:
|
||||
- name: "v1.12.4"
|
||||
tagline: "Preparation for future features"
|
||||
date: "November 11th 2025"
|
||||
# be sure to set this every new version
|
||||
isCurrent: true
|
||||
versions:
|
||||
- number: "Syncshells"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added a pause button for syncshells in grouped folders"
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed download notifications getting stuck at times"
|
||||
- "Added more offset positions for the notifications"
|
||||
- number: "Lightfinder"
|
||||
icon: ""
|
||||
items:
|
||||
- "Pair button will now show up when you're not directly paired. ie. You are technically paired in a syncshell, but you may not be directly paired'"
|
||||
- "Fixed a problem where the number of LightFinder users were cached, displaying the wrong information"
|
||||
- "When LightFinder is enabled, if you hover over the number of users, it will show you how many users are also using LightFinder in your area"
|
||||
-
|
||||
- number: "Bugfixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added even more checks to nameplate handler to help not only us debug, but also other plugins writing to the plate"
|
||||
- number: "Miscellaneous Changes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Default Linux to Websockets"
|
||||
- "Revised Brio warning"
|
||||
- "Added /lightless command to open Lightless UI (you can still use /light)"
|
||||
- "Initial groundwork for future features"
|
||||
- name: "v1.12.3"
|
||||
tagline: "LightSpeed, Welcome Screen, and More!"
|
||||
date: "October 15th 2025"
|
||||
versions:
|
||||
- number: "LightSpeed"
|
||||
icon: ""
|
||||
items:
|
||||
- "New way to download that will download mods directly from the file server"
|
||||
- "LightSpeed is in BETA and should be faster than the batch downloading"
|
||||
- number: "Welcome Screen + Additional Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "New in-game Patch Notes window."
|
||||
- "Credits section to thank contributors and supporters."
|
||||
- "Patch notes only show after updates, not during first-time setup."
|
||||
- "Syncshell Rework stared: Profiles have been added (more features using this will come later)."
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "More customizable notification options."
|
||||
- "Perfomance limiter shows as notifications."
|
||||
- "All notifications can be configured or disabled in Settings → Notifications."
|
||||
- "Cleaning up notifications implementation"
|
||||
- number: "Bugfixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added more safety checks to nameplates"
|
||||
- "Removed a line in SyncshellUI potentially causing NullPointers"
|
||||
- "Additional safety checks in PlayerData.Factory"
|
||||
- name: "v1.12.2"
|
||||
tagline: "LightFinder fixes, Notifications overhaul"
|
||||
date: "October 12th 2025"
|
||||
versions:
|
||||
- number: "LightFinder"
|
||||
icon: ""
|
||||
items:
|
||||
- "Server-side improvements for LightFinder functionality."
|
||||
- "Command changed from '/light lightfinder' to '/light finder'."
|
||||
- "Option to enable LightFinder on connection (opt-in, refreshes every 3 hours)."
|
||||
- "LightFinder indicator can now be shown on the server info bar."
|
||||
- number: "Notifications"
|
||||
icon: ""
|
||||
items:
|
||||
- "Completely reworked notification system with new UI."
|
||||
- "Pair requests now show as notifications."
|
||||
- "Download progress shows as notifications."
|
||||
- "Customizable notification sounds, size, position, and duration."
|
||||
- "All notifications can be configured or disabled in Settings → Notifications."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed nameplate alignment issues with LightFinder and icons."
|
||||
- "Icons now properly apply instead of swapping on choice."
|
||||
- "Updated Discord URL."
|
||||
- "File cache logic improvements."
|
||||
|
||||
- name: "v1.12.1"
|
||||
tagline: "LightFinder customization and download limiter"
|
||||
date: "October 8th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "LightFinder text can be modified to an icon with customizable positioning."
|
||||
- "Option to hide your own indicator or paired player indicators."
|
||||
- "Pair Download Limiter: Limit simultaneous downloads to 1-6 users to reduce network strain."
|
||||
- "Added '/light lightfinder' command to open LightFinder UI."
|
||||
- number: "Improvements"
|
||||
icon: ""
|
||||
items:
|
||||
- "Right-click menu option for Send Pair Request can be disabled."
|
||||
- "Syncshell finder improvements."
|
||||
- "Download limiter settings available in Settings → Transfers."
|
||||
|
||||
- name: "v1.12.0"
|
||||
tagline: "LightFinder - Major feature release"
|
||||
date: "October 5th 2025"
|
||||
versions:
|
||||
- number: "Major Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Introduced LightFinder: Optional feature inspired by FFXIV's Party Finder."
|
||||
- "Find fellow Lightless users and advertise your Syncshell to others."
|
||||
- "When enabled, you're visible to other LightFinder users for 3 hours."
|
||||
- "LightFinder tag displays above your nameplate when active."
|
||||
- "Receive pair requests directly in UI without exchanging UIDs."
|
||||
- "Syncshell Finder allows joining indexed Syncshells."
|
||||
- "[L] Send Pair Request added to player context menus."
|
||||
- number: "Vanity Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Supporters can now customize their name color in the Lightless UI."
|
||||
- "Color changes visible to all users."
|
||||
- number: "General Improvements"
|
||||
icon: ""
|
||||
items:
|
||||
- "Pairing nameplate color override can now override FC tags."
|
||||
- "Added .kdb as whitelisted filetype for uploads."
|
||||
- "Various UI fixes, updates, and improvements."
|
||||
|
||||
- name: "v1.11.12"
|
||||
tagline: "Syncshell grouping and performance options"
|
||||
date: "September 16th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Ability to show grouped syncshells in main UI/all syncshells (default ON)."
|
||||
- "Transfer ownership button available in Admin Panel user list."
|
||||
- "Self-threshold warning now opens character analysis screen when clicked."
|
||||
- number: "Performance"
|
||||
icon: ""
|
||||
items:
|
||||
- "Auto-pause combat and auto-pause performance are now optional settings."
|
||||
- "Both options are auto-enabled by default - disable at your own risk."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Reworked file caching to reduce errors for some users."
|
||||
- "Fixed bug where exiting PvP could desync some users."
|
||||
|
||||
- name: "v1.11.9"
|
||||
tagline: "File cache improvements"
|
||||
date: "September 13th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Identified and fixed potential file cache problems."
|
||||
- "Improved cache error handling and stability."
|
||||
|
||||
- name: "v1.11.8"
|
||||
tagline: "Hotfix - UI and exception handling"
|
||||
date: "September 12th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Attempted fix for NullReferenceException spam."
|
||||
- "Fixed additional UI edge cases preventing loading for some users."
|
||||
- "Fixed color bar UI issues."
|
||||
|
||||
- name: "v1.11.7"
|
||||
tagline: "Hotfix - UI loading and warnings"
|
||||
date: "September 12th 2025"
|
||||
versions:
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed UI not loading for some users."
|
||||
- "Self warnings now behind 'Warn on loading in players exceeding performance thresholds' setting."
|
||||
|
||||
- name: "v1.11.6"
|
||||
tagline: "Admin panel rework and new features"
|
||||
date: "September 11th 2025"
|
||||
versions:
|
||||
- number: "New Features"
|
||||
icon: ""
|
||||
items:
|
||||
- "Reworked Syncshell Admin Page with improved styling."
|
||||
- "Right-click on Server Top Bar button to disconnect from Lightless."
|
||||
- "Shift+Left click on Server Top Bar button to open settings."
|
||||
- "Added colors section in settings to change accent colors."
|
||||
- "Ability to pause syncing while in Instance/Duty."
|
||||
- "Functionality to create syncshell folders."
|
||||
- "Added self-threshold warning."
|
||||
- number: "Bug Fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Fixed owners being visible in moderator list view."
|
||||
- "Removed Pin/Remove/Ban buttons on Owners when viewing as moderator."
|
||||
- "Fixed nameplate bug in PvP."
|
||||
- "Added 1 or 3 day options for inactive check."
|
||||
- "Fixed bug where some users could not see their own syncshell folders."
|
||||
68
LightlessSync/Changelog/credits.yaml
Normal file
68
LightlessSync/Changelog/credits.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
credits:
|
||||
- category: "Development Team"
|
||||
items:
|
||||
- name: "Abel"
|
||||
role: "Developer"
|
||||
- name: "Cake"
|
||||
role: "Developer"
|
||||
- name: "Celine"
|
||||
role: "Developer"
|
||||
- name: "Choco"
|
||||
role: "Developer"
|
||||
- name: "Kenny"
|
||||
role: "Developer"
|
||||
- name: "Zura"
|
||||
role: "Developer"
|
||||
- name: "Additional Contributors"
|
||||
role: "Community Contributors & Bug Reporters"
|
||||
|
||||
- category: "Moderation Team"
|
||||
items:
|
||||
- name: "Crow"
|
||||
role: "Moderator"
|
||||
- name: "Faith"
|
||||
role: "Moderator"
|
||||
- name: "Kiwiwiwi"
|
||||
role: "Moderator"
|
||||
- name: "Kruwu"
|
||||
role: "Moderator"
|
||||
- name: "Lexi"
|
||||
role: "Moderator"
|
||||
- name: "Maya"
|
||||
role: "Moderator"
|
||||
- name: "Metaknight"
|
||||
role: "Moderator"
|
||||
- name: "Minmoose"
|
||||
role: "Moderator"
|
||||
- name: "Nihal"
|
||||
role: "Moderator"
|
||||
- name: "Tani"
|
||||
role: "Moderator"
|
||||
|
||||
- category: "Plugin Integration & IPC Support"
|
||||
items:
|
||||
- name: "Penumbra Team"
|
||||
role: "Mod framework integration"
|
||||
- name: "Glamourer Team"
|
||||
role: "Customization system integration"
|
||||
- name: "Customize+ Team"
|
||||
role: "Body scaling integration"
|
||||
- name: "Simple Heels Team"
|
||||
role: "Height offset integration"
|
||||
- name: "Honorific Team"
|
||||
role: "Title system integration"
|
||||
- name: "Glyceri"
|
||||
role: "Moodles - Status effect integration"
|
||||
- name: "Glyceri"
|
||||
role: "PetNicknames - Pet naming integration"
|
||||
- name: "Minmoose"
|
||||
role: "Brio - GPose enhancement integration"
|
||||
|
||||
- category: "Special Thanks"
|
||||
items:
|
||||
- name: "Dalamud & XIVLauncher Teams"
|
||||
role: "Plugin framework and infrastructure"
|
||||
- name: "Community Supporters"
|
||||
role: "Testing, feedback, and financial support"
|
||||
- name: "Beta Testers"
|
||||
role: "Early testing and bug reporting"
|
||||
@@ -20,7 +20,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
private long _currentFileProgress = 0;
|
||||
private CancellationTokenSource _scanCancellationTokenSource = new();
|
||||
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
|
||||
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk"];
|
||||
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
|
||||
|
||||
public CacheMonitor(ILogger<CacheMonitor> logger, IpcManager ipcManager, LightlessConfigService configService,
|
||||
FileCacheManager fileDbManager, LightlessMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil,
|
||||
@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public bool StorageisNTFS { get; private set; } = false;
|
||||
|
||||
public bool StorageIsBtrfs { get ; private set; } = false;
|
||||
|
||||
public void StartLightlessWatcher(string? lightlessPath)
|
||||
{
|
||||
LightlessWatcher?.Dispose();
|
||||
@@ -124,10 +126,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
|
||||
return;
|
||||
}
|
||||
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
|
||||
|
||||
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
|
||||
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
if (fsType == FileSystemHelper.FilesystemType.NTFS)
|
||||
{
|
||||
StorageisNTFS = true;
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
}
|
||||
|
||||
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
|
||||
{
|
||||
StorageIsBtrfs = true;
|
||||
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
|
||||
}
|
||||
|
||||
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
|
||||
LightlessWatcher = new()
|
||||
@@ -392,51 +403,94 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public void RecalculateFileCacheSize(CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
|
||||
!Directory.Exists(_configService.Current.CacheFolder))
|
||||
{
|
||||
FileCacheSize = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
FileCacheSize = -1;
|
||||
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
|
||||
bool isWine = _dalamudUtil?.IsWine ?? false;
|
||||
|
||||
try
|
||||
{
|
||||
FileCacheDriveFree = di.AvailableFreeSpace;
|
||||
var drive = DriveInfo.GetDrives()
|
||||
.FirstOrDefault(d => _configService.Current.CacheFolder
|
||||
.StartsWith(d.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (drive != null)
|
||||
FileCacheDriveFree = drive.AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
|
||||
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f))
|
||||
.OrderBy(f => f.LastAccessTime).ToList();
|
||||
FileCacheSize = files
|
||||
.Sum(f =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderBy(f => f.LastAccessTime)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
long size = 0;
|
||||
|
||||
if (!isWine)
|
||||
{
|
||||
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
|
||||
try
|
||||
{
|
||||
size = _fileCompactor.GetFileSizeOnDisk(f);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
|
||||
size = f.Length;
|
||||
}
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
size = f.Length;
|
||||
}
|
||||
});
|
||||
|
||||
totalSize += size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
FileCacheSize = totalSize;
|
||||
|
||||
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
|
||||
|
||||
if (FileCacheSize < maxCacheInBytes) return;
|
||||
if (FileCacheSize < maxCacheInBytes)
|
||||
return;
|
||||
|
||||
var maxCacheBuffer = maxCacheInBytes * 0.05d;
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
|
||||
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
|
||||
{
|
||||
var oldestFile = files[0];
|
||||
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
|
||||
File.Delete(oldestFile.FullName);
|
||||
files.Remove(oldestFile);
|
||||
|
||||
try
|
||||
{
|
||||
long fileSize = oldestFile.Length;
|
||||
File.Delete(oldestFile.FullName);
|
||||
FileCacheSize -= fileSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
|
||||
}
|
||||
|
||||
files.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,44 +698,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
// scan new files
|
||||
if (allScannedFiles.Any(c => !c.Value))
|
||||
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
|
||||
foreach (var cachePath in newFiles)
|
||||
{
|
||||
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
|
||||
new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = threadCount,
|
||||
CancellationToken = ct
|
||||
}, (cachePath) =>
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
if (ct.IsCancellationRequested) break;
|
||||
ProcessOne(cachePath);
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
void ProcessOne(string? cachePath)
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
|
||||
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
});
|
||||
|
||||
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (IOException ioex)
|
||||
{
|
||||
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug("Scan complete");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -16,14 +16,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
public const string CachePrefix = "{cache}";
|
||||
public const string CsvSplit = "|";
|
||||
public const string PenumbraPrefix = "{penumbra}";
|
||||
private const int FileCacheVersion = 1;
|
||||
private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:";
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly string _csvPath;
|
||||
private readonly ConcurrentDictionary<string, List<FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
||||
private readonly Lock _fileWriteLock = new();
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<FileCacheManager> _logger;
|
||||
private bool _csvHeaderEnsured;
|
||||
public string CacheFolder => _configService.Current.CacheFolder;
|
||||
|
||||
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
|
||||
@@ -37,12 +41,119 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
private string CsvBakPath => _csvPath + ".bak";
|
||||
|
||||
private static string NormalizeSeparators(string path)
|
||||
{
|
||||
return path.Replace("/", "\\", StringComparison.Ordinal)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizePrefixedPathKey(string prefixedPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefixedPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return NormalizeSeparators(prefixedPath).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryBuildPrefixedPath(string path, string? baseDirectory, string prefix, out string prefixedPath, out int matchedLength)
|
||||
{
|
||||
prefixedPath = string.Empty;
|
||||
matchedLength = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(baseDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedPath = NormalizeSeparators(path).ToLowerInvariant();
|
||||
var normalizedBase = NormalizeSeparators(baseDirectory).TrimEnd('\\').ToLowerInvariant();
|
||||
|
||||
if (!normalizedPath.StartsWith(normalizedBase, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedPath.Length > normalizedBase.Length)
|
||||
{
|
||||
if (normalizedPath[normalizedBase.Length] != '\\')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
prefixedPath = prefix + normalizedPath.Substring(normalizedBase.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
prefixedPath = prefix;
|
||||
}
|
||||
|
||||
prefixedPath = prefixedPath.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
matchedLength = normalizedBase.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}";
|
||||
|
||||
private static bool TryParseVersionHeader(string? line, out int version)
|
||||
{
|
||||
version = 0;
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!line.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionSpan = line.AsSpan(FileCacheVersionHeaderPrefix.Length);
|
||||
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
|
||||
}
|
||||
|
||||
private string NormalizeToPrefixedPath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||
|
||||
var normalized = NormalizeSeparators(path);
|
||||
|
||||
if (normalized.StartsWith(CachePrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizePrefixedPathKey(normalized);
|
||||
}
|
||||
|
||||
string? chosenPrefixed = null;
|
||||
var chosenLength = -1;
|
||||
|
||||
if (TryBuildPrefixedPath(normalized, _ipcManager.Penumbra.ModDirectory, PenumbraPrefix, out var penumbraPrefixed, out var penumbraMatch))
|
||||
{
|
||||
chosenPrefixed = penumbraPrefixed;
|
||||
chosenLength = penumbraMatch;
|
||||
}
|
||||
|
||||
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch))
|
||||
{
|
||||
if (cacheMatch > chosenLength)
|
||||
{
|
||||
chosenPrefixed = cachePrefixed;
|
||||
chosenLength = cacheMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizePrefixedPathKey(chosenPrefixed ?? normalized);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateCacheEntry(string path)
|
||||
{
|
||||
FileInfo fi = new(path);
|
||||
if (!fi.Exists) return null;
|
||||
_logger.LogTrace("Creating cache entry for {path}", path);
|
||||
return CreateFileEntity(_configService.Current.CacheFolder.ToLowerInvariant(), CachePrefix, fi);
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrEmpty(cacheFolder)) return null;
|
||||
return CreateFileEntity(cacheFolder, CachePrefix, fi);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateFileEntry(string path)
|
||||
@@ -50,31 +161,41 @@ public sealed class FileCacheManager : IHostedService
|
||||
FileInfo fi = new(path);
|
||||
if (!fi.Exists) return null;
|
||||
_logger.LogTrace("Creating file entry for {path}", path);
|
||||
return CreateFileEntity(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), PenumbraPrefix, fi);
|
||||
var modDirectory = _ipcManager.Penumbra.ModDirectory;
|
||||
if (string.IsNullOrEmpty(modDirectory)) return null;
|
||||
return CreateFileEntity(modDirectory, PenumbraPrefix, fi);
|
||||
}
|
||||
|
||||
private FileCacheEntity? CreateFileEntity(string directory, string prefix, FileInfo fi)
|
||||
{
|
||||
var fullName = fi.FullName.ToLowerInvariant();
|
||||
if (!fullName.Contains(directory, StringComparison.Ordinal)) return null;
|
||||
string prefixedPath = fullName.Replace(directory, prefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
if (!TryBuildPrefixedPath(fi.FullName, directory, prefix, out var prefixedPath, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateFileCacheEntity(fi, prefixedPath);
|
||||
}
|
||||
|
||||
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).ToList();
|
||||
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList();
|
||||
|
||||
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
|
||||
{
|
||||
List<FileCacheEntity> output = [];
|
||||
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
|
||||
{
|
||||
foreach (var fileCache in fileCacheEntities.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
|
||||
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
|
||||
{
|
||||
if (!validate) output.Add(fileCache);
|
||||
if (!validate)
|
||||
{
|
||||
output.Add(fileCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
var validated = GetValidatedFileCache(fileCache);
|
||||
if (validated != null) output.Add(validated);
|
||||
if (validated != null)
|
||||
{
|
||||
output.Add(validated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,42 +203,72 @@ public sealed class FileCacheManager : IHostedService
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
|
||||
public async Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
_logger.LogInformation("Validating local storage");
|
||||
var cacheEntries = _fileCaches.SelectMany(v => v.Value).Where(v => v.IsCacheEntry).ToList();
|
||||
List<FileCacheEntity> brokenEntities = [];
|
||||
int i = 0;
|
||||
foreach (var fileCache in cacheEntries)
|
||||
|
||||
var cacheEntries = _fileCaches.Values
|
||||
.SelectMany(v => v.Values)
|
||||
.Where(v => v.IsCacheEntry)
|
||||
.ToList();
|
||||
|
||||
int total = cacheEntries.Count;
|
||||
int processed = 0;
|
||||
var brokenEntities = new ConcurrentBag<FileCacheEntity>();
|
||||
|
||||
_logger.LogInformation("Checking {count} cache entries...", total);
|
||||
|
||||
await Parallel.ForEachAsync(cacheEntries, new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (fileCache, token) =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
|
||||
|
||||
progress.Report((i, cacheEntries.Count, fileCache));
|
||||
i++;
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
|
||||
int current = Interlocked.Increment(ref processed);
|
||||
if (current % 10 == 0)
|
||||
progress.Report((current, total, fileCache));
|
||||
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
string computedHash;
|
||||
try
|
||||
{
|
||||
computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
|
||||
_logger.LogInformation(
|
||||
"Hash mismatch: {file} (got {computedHash}, expected {expected})",
|
||||
fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
|
||||
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
|
||||
_logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
foreach (var brokenEntity in brokenEntities)
|
||||
{
|
||||
@@ -129,12 +280,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
|
||||
_logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath);
|
||||
}
|
||||
}
|
||||
|
||||
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
return Task.FromResult(brokenEntities);
|
||||
_logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count);
|
||||
|
||||
return [.. brokenEntities];
|
||||
}
|
||||
|
||||
public string GetCacheFilePath(string hash, string extension)
|
||||
@@ -151,29 +304,40 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
public FileCacheEntity? GetFileCacheByHash(string hash)
|
||||
{
|
||||
if (_fileCaches.TryGetValue(hash, out var hashes))
|
||||
if (_fileCaches.TryGetValue(hash, out var entries))
|
||||
{
|
||||
var item = hashes.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1).FirstOrDefault();
|
||||
if (item != null) return GetValidatedFileCache(item);
|
||||
var item = entries.Values
|
||||
.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1)
|
||||
.FirstOrDefault();
|
||||
if (item != null)
|
||||
{
|
||||
return GetValidatedFileCache(item);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private FileCacheEntity? GetFileCacheByPath(string path)
|
||||
{
|
||||
var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant()
|
||||
.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase);
|
||||
var entry = _fileCaches.SelectMany(v => v.Value).FirstOrDefault(f => f.ResolvedFilepath.EndsWith(cleanedPath, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (entry == null)
|
||||
var normalizedPrefixedPath = NormalizeToPrefixedPath(path);
|
||||
if (string.IsNullOrEmpty(normalizedPrefixedPath))
|
||||
{
|
||||
_logger.LogDebug("Found no entries for {path}", cleanedPath);
|
||||
return CreateFileEntry(path);
|
||||
return null;
|
||||
}
|
||||
|
||||
var validatedCacheEntry = GetValidatedFileCache(entry);
|
||||
if (_fileCachesByPrefixedPath.TryGetValue(normalizedPrefixedPath, out var entry))
|
||||
{
|
||||
return GetValidatedFileCache(entry);
|
||||
}
|
||||
|
||||
return validatedCacheEntry;
|
||||
_logger.LogDebug("Found no entries for {path}", normalizedPrefixedPath);
|
||||
|
||||
if (normalizedPrefixedPath.Contains(CachePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return CreateCacheEntry(path);
|
||||
}
|
||||
|
||||
return CreateFileEntry(path) ?? CreateCacheEntry(path);
|
||||
}
|
||||
|
||||
public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths)
|
||||
@@ -182,66 +346,55 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
try
|
||||
{
|
||||
var allEntities = _fileCaches.SelectMany(f => f.Value).ToArray();
|
||||
var result = new Dictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenNormalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var cacheDict = new ConcurrentDictionary<string, FileCacheEntity>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Parallel.ForEach(allEntities, entity =>
|
||||
foreach (var originalPath in paths)
|
||||
{
|
||||
cacheDict[entity.PrefixedFilePath] = entity;
|
||||
});
|
||||
|
||||
var cleanedPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenCleaned = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Parallel.ForEach(paths, p =>
|
||||
{
|
||||
var cleaned = p.Replace("/", "\\", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(
|
||||
_ipcManager.Penumbra.ModDirectory!,
|
||||
_ipcManager.Penumbra.ModDirectory!.EndsWith('\\')
|
||||
? PenumbraPrefix + '\\' : PenumbraPrefix,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(
|
||||
_configService.Current.CacheFolder,
|
||||
_configService.Current.CacheFolder.EndsWith('\\')
|
||||
? CachePrefix + '\\' : CachePrefix,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
|
||||
if (seenCleaned.TryAdd(cleaned, 0))
|
||||
if (string.IsNullOrEmpty(originalPath))
|
||||
{
|
||||
_logger.LogDebug("Adding to cleanedPaths: {cleaned}", cleaned);
|
||||
cleanedPaths[p] = cleaned;
|
||||
result[originalPath] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = NormalizeToPrefixedPath(originalPath);
|
||||
if (seenNormalized.Add(normalized))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
_logger.LogDebug("Normalized path {cleaned}", normalized);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
_logger.LogWarning("Duplicate normalized path detected: {cleaned}", normalized);
|
||||
}
|
||||
|
||||
if (_fileCachesByPrefixedPath.TryGetValue(normalized, out var entity))
|
||||
{
|
||||
result[originalPath] = GetValidatedFileCache(entity);
|
||||
continue;
|
||||
}
|
||||
|
||||
FileCacheEntity? created = null;
|
||||
|
||||
if (normalized.Contains(CachePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
created = CreateCacheEntry(originalPath);
|
||||
}
|
||||
else if (normalized.Contains(PenumbraPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
created = CreateFileEntry(originalPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Duplicate found: {cleaned}", cleaned);
|
||||
created = CreateFileEntry(originalPath) ?? CreateCacheEntry(originalPath);
|
||||
}
|
||||
});
|
||||
|
||||
var result = new ConcurrentDictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase);
|
||||
result[originalPath] = created;
|
||||
}
|
||||
|
||||
Parallel.ForEach(cleanedPaths, entry =>
|
||||
{
|
||||
_logger.LogDebug("Checking if in cache: {path}", entry.Value);
|
||||
|
||||
if (cacheDict.TryGetValue(entry.Value, out var entity))
|
||||
{
|
||||
var validatedCache = GetValidatedFileCache(entity);
|
||||
result[entry.Key] = validatedCache;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!entry.Value.Contains(CachePrefix, StringComparison.Ordinal))
|
||||
result[entry.Key] = CreateFileEntry(entry.Key);
|
||||
else
|
||||
result[entry.Key] = CreateCacheEntry(entry.Key);
|
||||
}
|
||||
});
|
||||
|
||||
return new Dictionary<string, FileCacheEntity?>(result, StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -251,16 +404,24 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
public void RemoveHashedFile(string hash, string prefixedFilePath)
|
||||
{
|
||||
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
|
||||
|
||||
if (_fileCaches.TryGetValue(hash, out var caches))
|
||||
{
|
||||
var removedCount = caches?.RemoveAll(c => string.Equals(c.PrefixedFilePath, prefixedFilePath, StringComparison.Ordinal));
|
||||
_logger.LogTrace("Removed from DB: {count} file(s) with hash {hash} and file cache {path}", removedCount, hash, prefixedFilePath);
|
||||
_logger.LogTrace("Removing from DB: {hash} => {path}", hash, prefixedFilePath);
|
||||
|
||||
if (caches?.Count == 0)
|
||||
if (caches.TryRemove(normalizedPath, out var removedEntity))
|
||||
{
|
||||
_fileCaches.Remove(hash, out var entity);
|
||||
_logger.LogTrace("Removed from DB: {hash} => {path}", hash, removedEntity.PrefixedFilePath);
|
||||
}
|
||||
|
||||
if (caches.IsEmpty)
|
||||
{
|
||||
_fileCaches.TryRemove(hash, out _);
|
||||
}
|
||||
}
|
||||
|
||||
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
|
||||
}
|
||||
|
||||
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
|
||||
@@ -301,7 +462,8 @@ public sealed class FileCacheManager : IHostedService
|
||||
lock (_fileWriteLock)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
|
||||
sb.AppendLine(BuildVersionHeader());
|
||||
foreach (var entry in _fileCaches.Values.SelectMany(k => k.Values).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine(entry.CsvEntry);
|
||||
}
|
||||
@@ -323,6 +485,66 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCsvHeaderLocked()
|
||||
{
|
||||
if (!File.Exists(_csvPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] existingLines = File.ReadAllLines(_csvPath);
|
||||
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
|
||||
{
|
||||
_csvHeaderEnsured = true;
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder rebuilt = new();
|
||||
rebuilt.AppendLine(BuildVersionHeader());
|
||||
foreach (var line in existingLines)
|
||||
{
|
||||
if (TryParseVersionHeader(line, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
{
|
||||
rebuilt.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(_csvPath, rebuilt.ToString());
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void EnsureCsvHeaderLockedCached()
|
||||
{
|
||||
if (_csvHeaderEnsured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCsvHeaderLocked();
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void BackupUnsupportedCache(string suffix)
|
||||
{
|
||||
var sanitizedSuffix = string.IsNullOrWhiteSpace(suffix) ? "unsupported" : $"{suffix}.unsupported";
|
||||
var backupPath = _csvPath + "." + sanitizedSuffix;
|
||||
|
||||
try
|
||||
{
|
||||
File.Move(_csvPath, backupPath, overwrite: true);
|
||||
_logger.LogWarning("Backed up unsupported file cache to {path}", backupPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to back up unsupported file cache to {path}", backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
internal FileCacheEntity MigrateFileHashToExtension(FileCacheEntity fileCache, string ext)
|
||||
{
|
||||
try
|
||||
@@ -346,16 +568,11 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
private void AddHashedFile(FileCacheEntity fileCache)
|
||||
{
|
||||
if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries) || entries is null)
|
||||
{
|
||||
_fileCaches[fileCache.Hash] = entries = [];
|
||||
}
|
||||
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
|
||||
var entries = _fileCaches.GetOrAdd(fileCache.Hash, _ => new ConcurrentDictionary<string, FileCacheEntity>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
entries.Add(fileCache);
|
||||
}
|
||||
entries[normalizedPath] = fileCache;
|
||||
_fileCachesByPrefixedPath[normalizedPath] = fileCache;
|
||||
}
|
||||
|
||||
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)
|
||||
@@ -366,7 +583,16 @@ public sealed class FileCacheManager : IHostedService
|
||||
AddHashedFile(entity);
|
||||
lock (_fileWriteLock)
|
||||
{
|
||||
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
|
||||
if (!File.Exists(_csvPath))
|
||||
{
|
||||
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureCsvHeaderLockedCached();
|
||||
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
|
||||
}
|
||||
}
|
||||
var result = GetFileCacheByPath(fileInfo.FullName);
|
||||
_logger.LogTrace("Creating cache entity for {name} success: {success}", fileInfo.FullName, (result != null));
|
||||
@@ -397,6 +623,12 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
private FileCacheEntity? Validate(FileCacheEntity fileCache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileCache.ResolvedFilepath))
|
||||
{
|
||||
_logger.LogWarning("FileCacheEntity has empty ResolvedFilepath for hash {hash}, prefixed path {prefixed}", fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
return null;
|
||||
}
|
||||
var file = new FileInfo(fileCache.ResolvedFilepath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
@@ -479,49 +711,111 @@ public sealed class FileCacheManager : IHostedService
|
||||
_logger.LogWarning("Could not load entries from {path}, continuing with empty file cache", _csvPath);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {amount} files in {path}", entries.Length, _csvPath);
|
||||
bool rewriteRequired = false;
|
||||
bool parseEntries = entries.Length > 0;
|
||||
int startIndex = 0;
|
||||
|
||||
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries)
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
|
||||
try
|
||||
var headerLine = entries[0];
|
||||
var hasHeader = !string.IsNullOrEmpty(headerLine) &&
|
||||
headerLine.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasHeader)
|
||||
{
|
||||
var hash = splittedEntry[0];
|
||||
if (hash.Length != 40) throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
|
||||
var path = splittedEntry[1];
|
||||
var time = splittedEntry[2];
|
||||
|
||||
if (processedFiles.ContainsKey(path))
|
||||
if (!TryParseVersionHeader(headerLine, out var parsedVersion))
|
||||
{
|
||||
_logger.LogWarning("Already processed {file}, ignoring", path);
|
||||
continue;
|
||||
_logger.LogWarning("Failed to parse file cache version header \"{header}\". Backing up existing cache.", headerLine);
|
||||
BackupUnsupportedCache("invalid-version");
|
||||
parseEntries = false;
|
||||
rewriteRequired = true;
|
||||
entries = Array.Empty<string>();
|
||||
}
|
||||
|
||||
processedFiles.Add(path, value: true);
|
||||
|
||||
long size = -1;
|
||||
long compressed = -1;
|
||||
if (splittedEntry.Length > 3)
|
||||
else if (parsedVersion != FileCacheVersion)
|
||||
{
|
||||
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
|
||||
{
|
||||
size = result;
|
||||
}
|
||||
if (long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
|
||||
{
|
||||
compressed = resultCompressed;
|
||||
}
|
||||
_logger.LogWarning("Unsupported file cache version {version} detected (expected {expected}). Backing up existing cache.", parsedVersion, FileCacheVersion);
|
||||
BackupUnsupportedCache($"v{parsedVersion}");
|
||||
parseEntries = false;
|
||||
rewriteRequired = true;
|
||||
entries = Array.Empty<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
startIndex = 1;
|
||||
}
|
||||
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
else if (entries.Length > 0)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
|
||||
_logger.LogInformation("File cache missing version header, scheduling rewrite.");
|
||||
rewriteRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (processedFiles.Count != entries.Length)
|
||||
var totalEntries = Math.Max(0, entries.Length - startIndex);
|
||||
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (parseEntries && totalEntries > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {amount} files in {path}", totalEntries, _csvPath);
|
||||
|
||||
for (var index = startIndex; index < entries.Length; index++)
|
||||
{
|
||||
var entry = entries[index];
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
|
||||
try
|
||||
{
|
||||
var hash = splittedEntry[0];
|
||||
if (hash.Length != 40)
|
||||
throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
|
||||
var path = splittedEntry[1];
|
||||
var time = splittedEntry[2];
|
||||
|
||||
if (processedFiles.ContainsKey(path))
|
||||
{
|
||||
_logger.LogWarning("Already processed {file}, ignoring", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
processedFiles.Add(path, value: true);
|
||||
|
||||
long size = -1;
|
||||
long compressed = -1;
|
||||
if (splittedEntry.Length > 3)
|
||||
{
|
||||
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
|
||||
{
|
||||
size = result;
|
||||
}
|
||||
if (splittedEntry.Length > 4 &&
|
||||
long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
|
||||
{
|
||||
compressed = resultCompressed;
|
||||
}
|
||||
}
|
||||
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedFiles.Count != totalEntries)
|
||||
{
|
||||
rewriteRequired = true;
|
||||
}
|
||||
}
|
||||
else if (!parseEntries && entries.Length > 0)
|
||||
{
|
||||
_logger.LogInformation("Skipping existing file cache entries due to incompatible version.");
|
||||
}
|
||||
|
||||
if (rewriteRequired)
|
||||
{
|
||||
WriteOutFullCsv();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
|
||||
private readonly TransientConfigService _configurationService;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk"];
|
||||
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
|
||||
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
|
||||
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
|
||||
private ConcurrentDictionary<IntPtr, ObjectKind> _cachedFrameAddresses = [];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
public enum LightfinderDtrDisplayMode
|
||||
{
|
||||
NearbyBroadcasts = 0,
|
||||
PendingPairRequests = 1,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Dalamud.Game.Text;
|
||||
using LightlessSync.UtilsEnum.Enum;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -20,6 +22,14 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public DtrEntry.Colors DtrColorsDefault { get; set; } = default;
|
||||
public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu);
|
||||
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u);
|
||||
public bool ShowLightfinderInDtr { get; set; } = false;
|
||||
public bool UseLightfinderColorsInDtr { get; set; } = true;
|
||||
public DtrEntry.Colors DtrColorsLightfinderEnabled { get; set; } = new(Foreground: 0xB590FFu, Glow: 0x4F406Eu);
|
||||
public DtrEntry.Colors DtrColorsLightfinderDisabled { get; set; } = new(Foreground: 0xD44444u, Glow: 0x642222u);
|
||||
public DtrEntry.Colors DtrColorsLightfinderCooldown { get; set; } = new(Foreground: 0xFFE97Au, Glow: 0x766C3Au);
|
||||
public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u);
|
||||
public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests;
|
||||
public bool UseLightlessRedesign { get; set; } = true;
|
||||
public bool EnableRightClickMenus { get; set; } = true;
|
||||
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
|
||||
public string ExportFolder { get; set; } = string.Empty;
|
||||
@@ -32,6 +42,9 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool OpenGposeImportOnGposeStart { get; set; } = false;
|
||||
public bool OpenPopupOnAdd { get; set; } = true;
|
||||
public int ParallelDownloads { get; set; } = 10;
|
||||
public int ParallelUploads { get; set; } = 8;
|
||||
public bool EnablePairProcessingLimiter { get; set; } = true;
|
||||
public int MaxConcurrentPairApplications { get; set; } = 3;
|
||||
public int DownloadSpeedLimitInBytes { get; set; } = 0;
|
||||
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
|
||||
public bool PreferNotesOverNamesForVisible { get; set; } = false;
|
||||
@@ -50,9 +63,11 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
|
||||
public bool ShowTransferBars { get; set; } = true;
|
||||
public bool ShowTransferWindow { get; set; } = false;
|
||||
public bool UseNotificationsForDownloads { get; set; } = true;
|
||||
public bool ShowUploading { get; set; } = true;
|
||||
public bool ShowUploadingBigText { get; set; } = true;
|
||||
public bool ShowVisibleUsersSeparately { get; set; } = true;
|
||||
public bool EnableDirectDownloads { get; set; } = true;
|
||||
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
|
||||
public int TransferBarsHeight { get; set; } = 12;
|
||||
public bool TransferBarsShowText { get; set; } = true;
|
||||
@@ -63,7 +78,74 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false;
|
||||
public int Version { get; set; } = 1;
|
||||
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
|
||||
|
||||
// Lightless Notification Configuration
|
||||
public bool UseLightlessNotifications { get; set; } = true;
|
||||
public bool ShowNotificationProgress { get; set; } = true;
|
||||
public NotificationLocation LightlessInfoNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
public NotificationLocation LightlessWarningNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
|
||||
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
|
||||
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||
|
||||
// Basic Settings
|
||||
public float NotificationOpacity { get; set; } = 0.95f;
|
||||
public int MaxSimultaneousNotifications { get; set; } = 5;
|
||||
public bool AutoDismissOnAction { get; set; } = true;
|
||||
public bool DismissNotificationOnClick { get; set; } = false;
|
||||
public bool ShowNotificationTimestamp { get; set; } = false;
|
||||
|
||||
// Position & Layout
|
||||
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
|
||||
public int NotificationOffsetY { get; set; } = 50;
|
||||
public int NotificationOffsetX { get; set; } = 0;
|
||||
public float NotificationWidth { get; set; } = 350f;
|
||||
public float NotificationSpacing { get; set; } = 8f;
|
||||
|
||||
// Animation & Effects
|
||||
public float NotificationAnimationSpeed { get; set; } = 10f;
|
||||
public float NotificationSlideSpeed { get; set; } = 10f;
|
||||
public float NotificationAccentBarWidth { get; set; } = 3f;
|
||||
|
||||
// Duration per Type
|
||||
public int InfoNotificationDurationSeconds { get; set; } = 10;
|
||||
public int WarningNotificationDurationSeconds { get; set; } = 15;
|
||||
public int ErrorNotificationDurationSeconds { get; set; } = 20;
|
||||
public int PairRequestDurationSeconds { get; set; } = 180;
|
||||
public int DownloadNotificationDurationSeconds { get; set; } = 30;
|
||||
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
|
||||
public uint CustomInfoSoundId { get; set; } = 2; // Se2
|
||||
public uint CustomWarningSoundId { get; set; } = 16; // Se15
|
||||
public uint CustomErrorSoundId { get; set; } = 16; // Se15
|
||||
public uint PairRequestSoundId { get; set; } = 5; // Se5
|
||||
public uint PerformanceSoundId { get; set; } = 16; // Se15
|
||||
public bool DisableInfoSound { get; set; } = true;
|
||||
public bool DisableWarningSound { get; set; } = true;
|
||||
public bool DisableErrorSound { get; set; } = true;
|
||||
public bool DisablePairRequestSound { get; set; } = true;
|
||||
public bool DisablePerformanceSound { get; set; } = true;
|
||||
public bool ShowPerformanceNotificationActions { get; set; } = true;
|
||||
public bool ShowPairRequestNotificationActions { get; set; } = true;
|
||||
public bool UseFocusTarget { get; set; } = false;
|
||||
public bool overrideFriendColor { get; set; } = false;
|
||||
public bool overridePartyColor { get; set; } = false;
|
||||
}
|
||||
public bool overrideFcTagColor { get; set; } = false;
|
||||
public bool useColoredUIDs { get; set; } = true;
|
||||
public bool BroadcastEnabled { get; set; } = false;
|
||||
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
|
||||
public short LightfinderLabelOffsetX { get; set; } = 0;
|
||||
public short LightfinderLabelOffsetY { get; set; } = 0;
|
||||
public bool LightfinderLabelUseIcon { get; set; } = false;
|
||||
public bool LightfinderLabelShowOwn { get; set; } = true;
|
||||
public bool LightfinderLabelShowPaired { get; set; } = true;
|
||||
public bool LightfinderLabelShowHidden { get; set; } = false;
|
||||
public string LightfinderLabelIconGlyph { get; set; } = SeIconCharExtensions.ToIconString(SeIconChar.Hyadelyn);
|
||||
public float LightfinderLabelScale { get; set; } = 1.0f;
|
||||
public bool LightfinderAutoAlign { get; set; } = true;
|
||||
public LabelAlignment LabelAlignment { get; set; } = LabelAlignment.Left;
|
||||
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
|
||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
public class ServerTagConfig : ILightlessConfiguration
|
||||
{
|
||||
public Dictionary<string, ServerTagStorage> ServerTagStorage { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
[Serializable]
|
||||
public class UiStyleOverride
|
||||
{
|
||||
public uint? Color { get; set; }
|
||||
public float? Float { get; set; }
|
||||
public Vector2Config? Vector2 { get; set; }
|
||||
|
||||
public bool IsEmpty => Color is null && Float is null && Vector2 is null;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public record struct Vector2Config(float X, float Y)
|
||||
{
|
||||
public static implicit operator Vector2(Vector2Config value) => new(value.X, value.Y);
|
||||
public static implicit operator Vector2Config(Vector2 value) => new(value.X, value.Y);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
[Serializable]
|
||||
public class UiThemeConfig : ILightlessConfiguration
|
||||
{
|
||||
public Dictionary<string, UiStyleOverride> StyleOverrides { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public int Version { get; set; } = 1;
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Models;
|
||||
namespace LightlessSync.LightlessConfiguration.Models;
|
||||
|
||||
public enum NotificationLocation
|
||||
{
|
||||
Nowhere,
|
||||
Chat,
|
||||
Toast,
|
||||
Both
|
||||
Both,
|
||||
LightlessUi,
|
||||
ChatAndLightlessUi,
|
||||
TextOverlay,
|
||||
}
|
||||
|
||||
public enum NotificationType
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
Error,
|
||||
PairRequest,
|
||||
Download,
|
||||
Performance
|
||||
}
|
||||
|
||||
public enum NotificationCorner
|
||||
{
|
||||
Right,
|
||||
Left
|
||||
}
|
||||
@@ -13,5 +13,4 @@ public class ServerStorage
|
||||
public bool UseOAuth2 { get; set; } = false;
|
||||
public string? OAuthToken { get; set; } = null;
|
||||
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
|
||||
public bool ForceWebSockets { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Models;
|
||||
|
||||
[Serializable]
|
||||
public class ServerTagStorage
|
||||
{
|
||||
public HashSet<string> OpenPairTags { get; set; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, List<string>> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration;
|
||||
|
||||
public class ServerTagConfigService : ConfigurationServiceBase<ServerTagConfig>
|
||||
{
|
||||
public const string ConfigName = "servertags.json";
|
||||
|
||||
public ServerTagConfigService(string configDir) : base(configDir)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ConfigurationName => ConfigName;
|
||||
}
|
||||
14
LightlessSync/LightlessConfiguration/UiThemeConfigService.cs
Normal file
14
LightlessSync/LightlessConfiguration/UiThemeConfigService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration;
|
||||
|
||||
public class UiThemeConfigService : ConfigurationServiceBase<UiThemeConfig>
|
||||
{
|
||||
public const string ConfigName = "ui-theme.json";
|
||||
|
||||
public UiThemeConfigService(string configDir) : base(configDir)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ConfigurationName => ConfigName;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using LightlessSync.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LightlessSync;
|
||||
@@ -101,7 +102,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
UIColors.Initialize(_lightlessConfigService);
|
||||
Mediator.StartQueueProcessing();
|
||||
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CheckVersion()
|
||||
{
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
|
||||
var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty;
|
||||
Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion);
|
||||
Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup());
|
||||
Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig());
|
||||
// Show update notes if version has changed and user has valid setup
|
||||
|
||||
if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) &&
|
||||
_lightlessConfigService.Current.HasValidSetup() &&
|
||||
_serverConfigurationManager.HasValidConfig())
|
||||
{
|
||||
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DalamudUtilOnLogIn()
|
||||
{
|
||||
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
|
||||
CheckVersion();
|
||||
|
||||
#if !DEBUG
|
||||
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
|
||||
<PropertyGroup>
|
||||
<Authors></Authors>
|
||||
<Company></Company>
|
||||
<Version>1.11.12</Version>
|
||||
<Version>1.12.3.1</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
@@ -46,6 +46,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -64,6 +65,8 @@
|
||||
<None Update="images\icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||
<EmbeddedResource Include="Localization\de.json" />
|
||||
<EmbeddedResource Include="Localization\fr.json" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,21 +12,38 @@ public class FileDownloadManagerFactory
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly FileCompactor _fileCompactor;
|
||||
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly LightlessConfigService _configService;
|
||||
|
||||
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator,
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
|
||||
public FileDownloadManagerFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
LightlessMediator lightlessMediator,
|
||||
FileTransferOrchestrator fileTransferOrchestrator,
|
||||
FileCacheManager fileCacheManager,
|
||||
FileCompactor fileCompactor,
|
||||
PairProcessingLimiter pairProcessingLimiter,
|
||||
LightlessConfigService configService)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_fileTransferOrchestrator = fileTransferOrchestrator;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_fileCompactor = fileCompactor;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public FileDownloadManager Create()
|
||||
{
|
||||
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
|
||||
return new FileDownloadManager(
|
||||
_loggerFactory.CreateLogger<FileDownloadManager>(),
|
||||
_lightlessMediator,
|
||||
_fileTransferOrchestrator,
|
||||
_fileCacheManager,
|
||||
_fileCompactor,
|
||||
_pairProcessingLimiter,
|
||||
_configService);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
@@ -21,6 +21,7 @@ public class PairHandlerFactory
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly PlayerPerformanceService _playerPerformanceService;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
|
||||
|
||||
@@ -28,6 +29,7 @@ public class PairHandlerFactory
|
||||
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
|
||||
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
|
||||
FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService,
|
||||
PairProcessingLimiter pairProcessingLimiter,
|
||||
ServerConfigurationManager serverConfigManager)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
@@ -40,6 +42,7 @@ public class PairHandlerFactory
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_playerPerformanceService = playerPerformanceService;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_serverConfigManager = serverConfigManager;
|
||||
}
|
||||
|
||||
@@ -47,6 +50,6 @@ public class PairHandlerFactory
|
||||
{
|
||||
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory,
|
||||
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
|
||||
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _serverConfigManager);
|
||||
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,19 @@ public class PlayerDataFactory
|
||||
|
||||
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
{
|
||||
return ((Character*)playerPointer)->GameObject.DrawObject == null;
|
||||
if (playerPointer == IntPtr.Zero)
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
var gameObject = &character->GameObject;
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
@@ -28,6 +28,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly PlayerPerformanceService _playerPerformanceService;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
|
||||
private CancellationTokenSource? _applicationCancellationTokenSource = new();
|
||||
@@ -50,6 +51,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
|
||||
FileCacheManager fileDbManager, LightlessMediator mediator,
|
||||
PlayerPerformanceService playerPerformanceService,
|
||||
PairProcessingLimiter pairProcessingLimiter,
|
||||
ServerConfigurationManager serverConfigManager) : base(logger, mediator)
|
||||
{
|
||||
Pair = pair;
|
||||
@@ -61,6 +63,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
_lifetime = lifetime;
|
||||
_fileDbManager = fileDbManager;
|
||||
_playerPerformanceService = playerPerformanceService;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_serverConfigManager = serverConfigManager;
|
||||
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
@@ -420,6 +423,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
||||
bool updateModdedPaths, bool updateManip, CancellationToken downloadToken)
|
||||
{
|
||||
await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
|
||||
Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
|
||||
|
||||
if (updateModdedPaths)
|
||||
@@ -737,6 +741,11 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
|
||||
@@ -763,4 +772,4 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
|
||||
_dataReceivedInDowntime = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Comparer;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
@@ -7,10 +7,14 @@ using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.Services;
|
||||
|
||||
using LightlessSync.Services.Events;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
@@ -24,14 +28,19 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
private Lazy<List<Pair>> _directPairsInternal;
|
||||
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> _groupPairsInternal;
|
||||
private Lazy<Dictionary<Pair, List<GroupFullInfoDto>>> _pairsWithGroupsInternal;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ConcurrentQueue<(Pair Pair, OnlineUserIdentDto? Ident)> _pairCreationQueue = new();
|
||||
private CancellationTokenSource _pairCreationCts = new();
|
||||
private int _pairCreationProcessorRunning;
|
||||
|
||||
public PairManager(ILogger<PairManager> logger, PairFactory pairFactory,
|
||||
LightlessConfigService configurationService, LightlessMediator mediator,
|
||||
IContextMenu dalamudContextMenu) : base(logger, mediator)
|
||||
IContextMenu dalamudContextMenu, PairProcessingLimiter pairProcessingLimiter) : base(logger, mediator)
|
||||
{
|
||||
_pairFactory = pairFactory;
|
||||
_configurationService = configurationService;
|
||||
_dalamudContextMenu = dalamudContextMenu;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (_) => ClearPairs());
|
||||
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => ReapplyPairData());
|
||||
_directPairsInternal = DirectPairsLazy();
|
||||
@@ -112,6 +121,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
public void ClearPairs()
|
||||
{
|
||||
Logger.LogDebug("Clearing all Pairs");
|
||||
ResetPairCreationQueue();
|
||||
DisposePairs();
|
||||
_allClientPairs.Clear();
|
||||
_allGroups.Clear();
|
||||
@@ -128,7 +138,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (_allClientPairs.TryGetValue(user, out var pair))
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(pair.UserData));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData));
|
||||
pair.MarkOffline();
|
||||
}
|
||||
|
||||
@@ -139,7 +149,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
|
||||
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
|
||||
var pair = _allClientPairs[dto.User];
|
||||
if (pair.HasCachedPlayer)
|
||||
@@ -161,7 +171,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
pair.CreateCachedPlayer(dto);
|
||||
QueuePairCreation(pair, dto);
|
||||
|
||||
RecreateLazy();
|
||||
}
|
||||
@@ -244,7 +254,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused())
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
}
|
||||
|
||||
pair.UserPair.OtherPermissions = dto.Permissions;
|
||||
@@ -270,7 +280,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused())
|
||||
{
|
||||
Mediator.Publish(new ClearProfileDataMessage(dto.User));
|
||||
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
|
||||
}
|
||||
|
||||
pair.UserPair.OwnPermissions = dto.Permissions;
|
||||
@@ -332,6 +342,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
ResetPairCreationQueue();
|
||||
_dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu;
|
||||
|
||||
DisposePairs();
|
||||
@@ -390,6 +401,84 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
|
||||
});
|
||||
}
|
||||
|
||||
private void QueuePairCreation(Pair pair, OnlineUserIdentDto? dto)
|
||||
{
|
||||
if (pair.HasCachedPlayer)
|
||||
{
|
||||
RecreateLazy();
|
||||
return;
|
||||
}
|
||||
|
||||
_pairCreationQueue.Enqueue((pair, dto));
|
||||
StartPairCreationProcessor();
|
||||
}
|
||||
|
||||
private void StartPairCreationProcessor()
|
||||
{
|
||||
if (_pairCreationCts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _pairCreationProcessorRunning, 1, 0) == 0)
|
||||
{
|
||||
_ = Task.Run(ProcessPairCreationQueueAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPairCreationQueueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_pairCreationCts.IsCancellationRequested)
|
||||
{
|
||||
if (!_pairCreationQueue.TryDequeue(out var work))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var lease = await _pairProcessingLimiter.AcquireAsync(_pairCreationCts.Token).ConfigureAwait(false);
|
||||
if (!work.Pair.HasCachedPlayer)
|
||||
{
|
||||
work.Pair.CreateCachedPlayer(work.Ident);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error creating cached player for {uid}", work.Pair.UserData.UID);
|
||||
}
|
||||
|
||||
RecreateLazy();
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _pairCreationProcessorRunning, 0);
|
||||
if (!_pairCreationQueue.IsEmpty && !_pairCreationCts.IsCancellationRequested)
|
||||
{
|
||||
StartPairCreationProcessor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetPairCreationQueue()
|
||||
{
|
||||
_pairCreationCts.Cancel();
|
||||
while (_pairCreationQueue.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
_pairCreationCts.Dispose();
|
||||
_pairCreationCts = new CancellationTokenSource();
|
||||
Interlocked.Exchange(ref _pairCreationProcessorRunning, 0);
|
||||
}
|
||||
|
||||
private void ReapplyPairData()
|
||||
{
|
||||
foreach (var pair in _allClientPairs.Select(k => k.Value))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
@@ -101,6 +101,8 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
|
||||
|
||||
if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced)
|
||||
@@ -127,6 +129,15 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
|
||||
_pushDataSemaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogDebug("PushCharacterData cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to push character data");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game.ClientState.Objects;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Windowing;
|
||||
@@ -41,7 +41,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui,
|
||||
IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager,
|
||||
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
|
||||
ISigScanner sigScanner, INamePlateGui namePlateGui)
|
||||
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
|
||||
{
|
||||
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
|
||||
Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName);
|
||||
@@ -90,6 +90,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddSingleton(new WindowSystem("LightlessSync"));
|
||||
collection.AddSingleton<FileDialogManager>();
|
||||
collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true));
|
||||
collection.AddSingleton(gameGui);
|
||||
|
||||
// add lightless related singletons
|
||||
collection.AddSingleton<LightlessMediator>();
|
||||
@@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddSingleton<GameObjectHandlerFactory>();
|
||||
collection.AddSingleton<FileDownloadManagerFactory>();
|
||||
collection.AddSingleton<PairHandlerFactory>();
|
||||
collection.AddSingleton<PairProcessingLimiter>();
|
||||
collection.AddSingleton<PairFactory>();
|
||||
collection.AddSingleton<XivDataAnalyzer>();
|
||||
collection.AddSingleton<CharacterAnalyzer>();
|
||||
@@ -112,6 +114,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddSingleton<PluginWarningNotificationService>();
|
||||
collection.AddSingleton<FileCompactor>();
|
||||
collection.AddSingleton<TagHandler>();
|
||||
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
|
||||
collection.AddSingleton<PairRequestService>();
|
||||
collection.AddSingleton<IdDisplayHandler>();
|
||||
collection.AddSingleton<PlayerPerformanceService>();
|
||||
collection.AddSingleton<TransientResourceManager>();
|
||||
@@ -139,11 +143,26 @@ public sealed class Plugin : IDalamudPlugin
|
||||
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
|
||||
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
|
||||
collection.AddSingleton((s) => new DtrEntry(s.GetRequiredService<ILogger<DtrEntry>>(), dtrBar, s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<ServerConfigurationManager>()));
|
||||
collection.AddSingleton((s) => new DtrEntry(
|
||||
s.GetRequiredService<ILogger<DtrEntry>>(),
|
||||
dtrBar,
|
||||
s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PairManager>(),
|
||||
s.GetRequiredService<PairRequestService>(),
|
||||
s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<ServerConfigurationManager>(),
|
||||
s.GetRequiredService<BroadcastService>(),
|
||||
s.GetRequiredService<BroadcastScannerService>(),
|
||||
s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu));
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
|
||||
collection.AddSingleton<RedrawManager>();
|
||||
collection.AddSingleton<BroadcastService>();
|
||||
collection.AddSingleton(addonLifecycle);
|
||||
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData,
|
||||
p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
|
||||
p.GetRequiredService<LightlessConfigService>(), p.GetRequiredService<PairRequestService>(), p.GetRequiredService<PairManager>(), clientState));
|
||||
collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService<ILogger<IpcCallerPenumbra>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
|
||||
collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService<ILogger<IpcCallerGlamourer>>(), pluginInterface,
|
||||
@@ -164,9 +183,14 @@ public sealed class Plugin : IDalamudPlugin
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
|
||||
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
|
||||
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
|
||||
collection.AddSingleton((s) => new NotificationService(s.GetRequiredService<ILogger<NotificationService>>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<DalamudUtilService>(),
|
||||
notificationManager, chatGui, s.GetRequiredService<LightlessConfigService>()));
|
||||
collection.AddSingleton((s) => new NotificationService(
|
||||
s.GetRequiredService<ILogger<NotificationService>>(),
|
||||
s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<DalamudUtilService>(),
|
||||
notificationManager,
|
||||
chatGui,
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PairRequestService>()));
|
||||
collection.AddSingleton((s) =>
|
||||
{
|
||||
var httpClient = new HttpClient();
|
||||
@@ -174,7 +198,14 @@ public sealed class Plugin : IDalamudPlugin
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
|
||||
return httpClient;
|
||||
});
|
||||
collection.AddSingleton((s) => new LightlessConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) =>
|
||||
{
|
||||
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
|
||||
var theme = s.GetRequiredService<UiThemeConfigService>();
|
||||
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
|
||||
return cfg;
|
||||
});
|
||||
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
@@ -184,6 +215,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<LightlessConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<UiThemeConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
|
||||
@@ -194,8 +226,9 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
|
||||
collection.AddSingleton<ConfigurationMigrator>();
|
||||
collection.AddSingleton<ConfigurationSaveService>();
|
||||
|
||||
collection.AddSingleton<HubFactory>();
|
||||
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
|
||||
|
||||
|
||||
// add scoped services
|
||||
collection.AddScoped<DrawEntityFactory>();
|
||||
@@ -213,12 +246,21 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
|
||||
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
|
||||
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>((s) =>
|
||||
new LightlessNotificationUi(
|
||||
s.GetRequiredService<ILogger<LightlessNotificationUi>>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<LightlessConfigService>()));
|
||||
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||
collection.AddScoped<CacheCreationService>();
|
||||
collection.AddScoped<PlayerDataFactory>();
|
||||
@@ -226,7 +268,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
|
||||
s.GetRequiredService<UiFactory>(),
|
||||
s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessMediator>()));
|
||||
s.GetRequiredService<FileDialogManager>(),
|
||||
s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
|
||||
@@ -236,6 +279,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
|
||||
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
|
||||
|
||||
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
|
||||
@@ -248,6 +293,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<LightlessPlugin>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<ContextMenuService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<BroadcastService>());
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -259,4 +306,4 @@ public sealed class Plugin : IDalamudPlugin
|
||||
_host.StopAsync().GetAwaiter().GetResult();
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
242
LightlessSync/Services/BroadcastScanningService.cs
Normal file
242
LightlessSync/Services/BroadcastScanningService.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable
|
||||
{
|
||||
private readonly ILogger<BroadcastScannerService> _logger;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
private readonly BroadcastService _broadcastService;
|
||||
private readonly NameplateHandler _nameplateHandler;
|
||||
|
||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new();
|
||||
private readonly Queue<string> _lookupQueue = new();
|
||||
private readonly HashSet<string> _lookupQueuedCids = new();
|
||||
private readonly HashSet<string> _syncshellCids = new();
|
||||
|
||||
private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly CancellationTokenSource _cleanupCts = new();
|
||||
private Task? _cleanupTask;
|
||||
|
||||
private readonly int _checkEveryFrames = 20;
|
||||
private int _frameCounter = 0;
|
||||
private int _lookupsThisFrame = 0;
|
||||
private const int MaxLookupsPerFrame = 30;
|
||||
private const int MaxQueueSize = 100;
|
||||
|
||||
private volatile bool _batchRunning = false;
|
||||
|
||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||
|
||||
public BroadcastScannerService(ILogger<BroadcastScannerService> logger,
|
||||
IClientState clientState,
|
||||
IObjectTable objectTable,
|
||||
IFramework framework,
|
||||
BroadcastService broadcastService,
|
||||
LightlessMediator mediator,
|
||||
NameplateHandler nameplateHandler,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessConfigService configService) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_objectTable = objectTable;
|
||||
_broadcastService = broadcastService;
|
||||
_nameplateHandler = nameplateHandler;
|
||||
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
_framework.Update += OnFrameworkUpdate;
|
||||
|
||||
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
|
||||
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop);
|
||||
|
||||
_nameplateHandler.Init();
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework) => Update();
|
||||
|
||||
public void Update()
|
||||
{
|
||||
_frameCounter++;
|
||||
_lookupsThisFrame = 0;
|
||||
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero)
|
||||
continue;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address);
|
||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||
|
||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize)
|
||||
_lookupQueue.Enqueue(cid);
|
||||
}
|
||||
|
||||
if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0)
|
||||
{
|
||||
var cidsToLookup = new List<string>();
|
||||
while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame)
|
||||
{
|
||||
var cid = _lookupQueue.Dequeue();
|
||||
_lookupQueuedCids.Remove(cid);
|
||||
cidsToLookup.Add(cid);
|
||||
_lookupsThisFrame++;
|
||||
}
|
||||
|
||||
if (cidsToLookup.Count > 0 && !_batchRunning)
|
||||
{
|
||||
_batchRunning = true;
|
||||
_ = BatchUpdateBroadcastCacheAsync(cidsToLookup).ContinueWith(_ => _batchRunning = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||
{
|
||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, info) in results)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cid) || info == null)
|
||||
continue;
|
||||
|
||||
var ttl = info.IsBroadcasting && info.TTL.HasValue
|
||||
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks))
|
||||
: RetryDelay;
|
||||
|
||||
var expiry = now + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(cid,
|
||||
new BroadcastEntry(info.IsBroadcasting, expiry, info.GID),
|
||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||
}
|
||||
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_nameplateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
_broadcastCache.Clear();
|
||||
_lookupQueue.Clear();
|
||||
_lookupQueuedCids.Clear();
|
||||
_syncshellCids.Clear();
|
||||
|
||||
_nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var newSet = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Select(e => e.Key)
|
||||
.ToHashSet();
|
||||
|
||||
if (!_syncshellCids.SetEquals(newSet))
|
||||
{
|
||||
_syncshellCids.Clear();
|
||||
foreach (var cid in newSet)
|
||||
_syncshellCids.Add(cid);
|
||||
|
||||
Mediator.Publish(new SyncshellBroadcastsUpdatedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Select(e => new BroadcastStatusInfoDto
|
||||
{
|
||||
HashedCID = e.Key,
|
||||
IsBroadcasting = true,
|
||||
TTL = e.Value.ExpiryTime - now,
|
||||
GID = e.Value.GID
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task ExpiredBroadcastCleanupLoop()
|
||||
{
|
||||
var token = _cleanupCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), token);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (cid, entry) in _broadcastCache.ToArray())
|
||||
{
|
||||
if (entry.ExpiryTime <= now)
|
||||
_broadcastCache.TryRemove(cid, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Broadcast cleanup loop crashed");
|
||||
}
|
||||
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
public int CountActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return _broadcastCache.Count(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
|
||||
}
|
||||
|
||||
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return [.. _broadcastCache.Where(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
_framework.Update -= OnFrameworkUpdate;
|
||||
_cleanupCts.Cancel();
|
||||
_cleanupTask?.Wait(100);
|
||||
_nameplateHandler.Uninit();
|
||||
}
|
||||
}
|
||||
578
LightlessSync/Services/BroadcastService.cs
Normal file
578
LightlessSync/Services/BroadcastService.cs
Normal file
@@ -0,0 +1,578 @@
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
private readonly ILogger<BroadcastService> _logger;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly LightlessMediator _mediator;
|
||||
private readonly LightlessConfigService _config;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private CancellationTokenSource? _lightfinderCancelTokens;
|
||||
private Action? _connectedHandler;
|
||||
public LightlessMediator Mediator => _mediator;
|
||||
|
||||
public bool IsLightFinderAvailable { get; private set; } = false;
|
||||
|
||||
public bool IsBroadcasting => _config.Current.BroadcastEnabled;
|
||||
private bool _syncedOnStartup = false;
|
||||
private bool _waitingForTtlFetch = false;
|
||||
private TimeSpan? _remainingTtl = null;
|
||||
private DateTime _lastTtlCheck = DateTime.MinValue;
|
||||
private DateTime _lastForcedDisableTime = DateTime.MinValue;
|
||||
private static readonly TimeSpan _disableCooldown = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan? RemainingTtl => _remainingTtl;
|
||||
public TimeSpan? RemainingCooldown
|
||||
{
|
||||
get
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _lastForcedDisableTime;
|
||||
if (elapsed >= _disableCooldown) return null;
|
||||
return _disableCooldown - elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
_config = config;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_apiController = apiController;
|
||||
}
|
||||
|
||||
private async Task RequireConnectionAsync(string context, Func<Task> action)
|
||||
{
|
||||
if (!_apiController.IsConnected)
|
||||
{
|
||||
_logger.LogDebug(context + " skipped, not connected");
|
||||
return;
|
||||
}
|
||||
await action().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<string?> GetLocalHashedCidAsync(string context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false);
|
||||
return cid.ToString().GetHash256();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resolve CID for {Context}", context);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyBroadcastDisabled(bool forcePublish = false)
|
||||
{
|
||||
bool wasEnabled = _config.Current.BroadcastEnabled;
|
||||
bool hadExpiry = _config.Current.BroadcastTtl != DateTime.MinValue;
|
||||
bool hadRemaining = _remainingTtl.HasValue;
|
||||
|
||||
_config.Current.BroadcastEnabled = false;
|
||||
_config.Current.BroadcastTtl = DateTime.MinValue;
|
||||
|
||||
if (wasEnabled || hadExpiry)
|
||||
_config.Save();
|
||||
|
||||
_remainingTtl = null;
|
||||
_waitingForTtlFetch = false;
|
||||
_syncedOnStartup = false;
|
||||
|
||||
if (forcePublish || wasEnabled || hadRemaining)
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
|
||||
}
|
||||
|
||||
private bool TryApplyBroadcastEnabled(TimeSpan? ttl, string context)
|
||||
{
|
||||
if (ttl is not { } validTtl || validTtl <= TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning("Lightfinder enable skipped ({Context}): invalid TTL ({TTL})", context, ttl);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool wasEnabled = _config.Current.BroadcastEnabled;
|
||||
TimeSpan? previousRemaining = _remainingTtl;
|
||||
DateTime previousExpiry = _config.Current.BroadcastTtl;
|
||||
|
||||
var newExpiry = DateTime.UtcNow + validTtl;
|
||||
|
||||
_config.Current.BroadcastEnabled = true;
|
||||
_config.Current.BroadcastTtl = newExpiry;
|
||||
|
||||
if (!wasEnabled || previousExpiry != newExpiry)
|
||||
_config.Save();
|
||||
|
||||
_remainingTtl = validTtl;
|
||||
_waitingForTtlFetch = false;
|
||||
|
||||
if (!wasEnabled || previousRemaining != validTtl)
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
||||
|
||||
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void HandleLightfinderUnavailable(string message, Exception? ex = null)
|
||||
{
|
||||
if (ex != null)
|
||||
_logger.LogWarning(ex, message);
|
||||
else
|
||||
_logger.LogWarning(message);
|
||||
|
||||
IsLightFinderAvailable = false;
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
}
|
||||
|
||||
private void OnDisconnected()
|
||||
{
|
||||
IsLightFinderAvailable = false;
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
|
||||
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_mediator.Subscribe<EnableBroadcastMessage>(this, OnEnableBroadcast);
|
||||
_mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
|
||||
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
|
||||
_mediator.Subscribe<DisconnectedMessage>(this, _ => OnDisconnected());
|
||||
|
||||
IsLightFinderAvailable = false;
|
||||
|
||||
_lightfinderCancelTokens?.Cancel();
|
||||
_lightfinderCancelTokens?.Dispose();
|
||||
_lightfinderCancelTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_connectedHandler = () => _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
|
||||
_apiController.OnConnected += _connectedHandler;
|
||||
|
||||
if (_apiController.IsConnected)
|
||||
_ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_lightfinderCancelTokens?.Cancel();
|
||||
_lightfinderCancelTokens?.Dispose();
|
||||
_lightfinderCancelTokens = null;
|
||||
|
||||
if (_connectedHandler is not null)
|
||||
{
|
||||
_apiController.OnConnected -= _connectedHandler;
|
||||
_connectedHandler = null;
|
||||
}
|
||||
|
||||
_mediator.UnsubscribeAll(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested)
|
||||
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
var hashedCid = await GetLocalHashedCidAsync("Lightfinder state check").ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(hashedCid))
|
||||
return;
|
||||
|
||||
BroadcastStatusInfoDto? status = null;
|
||||
try
|
||||
{
|
||||
status = await _apiController.IsUserBroadcasting(hashedCid).ConfigureAwait(false);
|
||||
}
|
||||
catch (HubException ex) when (ex.Message.Contains("Method does not exist", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleLightfinderUnavailable("Lightfinder unavailable on server (required method missing).", ex);
|
||||
}
|
||||
|
||||
if (!IsLightFinderAvailable)
|
||||
_logger.LogInformation("Lightfinder is available.");
|
||||
|
||||
IsLightFinderAvailable = true;
|
||||
|
||||
bool isBroadcasting = status?.IsBroadcasting == true;
|
||||
TimeSpan? ttl = status?.TTL;
|
||||
|
||||
if (isBroadcasting)
|
||||
{
|
||||
if (ttl is not { } remaining || remaining <= TimeSpan.Zero)
|
||||
ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
|
||||
|
||||
if (TryApplyBroadcastEnabled(ttl, "server handshake"))
|
||||
{
|
||||
_syncedOnStartup = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
isBroadcasting = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBroadcasting)
|
||||
{
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
_logger.LogInformation("Lightfinder is available but no active broadcast was found.");
|
||||
}
|
||||
|
||||
if (_config.Current.LightfinderAutoEnableOnConnect && !isBroadcasting)
|
||||
{
|
||||
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Auto-Enabled",
|
||||
"Your Lightfinder broadcast has been automatically enabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Lightfinder check was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
HandleLightfinderUnavailable("Lightfinder check failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnableBroadcast(EnableBroadcastMessage msg)
|
||||
{
|
||||
_ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
GroupBroadcastRequestDto? groupDto = null;
|
||||
if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null)
|
||||
{
|
||||
groupDto = new GroupBroadcastRequestDto
|
||||
{
|
||||
HashedCID = msg.HashedCid,
|
||||
GID = _config.Current.SelectedFinderSyncshell,
|
||||
Enabled = msg.Enabled,
|
||||
};
|
||||
}
|
||||
|
||||
await _apiController.SetBroadcastStatus(msg.Enabled, groupDto).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid);
|
||||
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
return;
|
||||
}
|
||||
|
||||
_waitingForTtlFetch = true;
|
||||
|
||||
try
|
||||
{
|
||||
TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false);
|
||||
|
||||
if (TryApplyBroadcastEnabled(ttl, "client request"))
|
||||
{
|
||||
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
|
||||
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
_logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_waitingForTtlFetch = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid);
|
||||
_waitingForTtlFetch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
_config.Current.BroadcastEnabled = msg.Enabled;
|
||||
_config.Save();
|
||||
}
|
||||
|
||||
public async Task<bool> CheckIfBroadcastingAsync(string targetCid)
|
||||
{
|
||||
bool result = false;
|
||||
await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("[BroadcastCheck] Checking CID: {cid}", targetCid);
|
||||
|
||||
var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false);
|
||||
result = info?.TTL > TimeSpan.Zero;
|
||||
|
||||
|
||||
_logger.LogDebug("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<TimeSpan?> GetBroadcastTtlAsync(string? cidForLog = null)
|
||||
{
|
||||
TimeSpan? ttl = null;
|
||||
await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => {
|
||||
try
|
||||
{
|
||||
ttl = await _apiController.GetBroadcastTtl().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (cidForLog is { Length: > 0 })
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch broadcast TTL for {Cid}", cidForLog);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch broadcast TTL");
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
return ttl;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
|
||||
{
|
||||
Dictionary<string, BroadcastStatusInfoDto?> result = new();
|
||||
|
||||
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false);
|
||||
|
||||
if (batch?.Results != null)
|
||||
{
|
||||
foreach (var kv in batch.Results)
|
||||
result[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to batch check broadcast status");
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async void ToggleBroadcast()
|
||||
{
|
||||
|
||||
if (!IsLightFinderAvailable)
|
||||
{
|
||||
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Unavailable",
|
||||
"Lightfinder is not available on this server.",
|
||||
NotificationType.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
await RequireConnectionAsync(nameof(ToggleBroadcast), async () =>
|
||||
{
|
||||
var cooldown = RemainingCooldown;
|
||||
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Cooldown",
|
||||
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
|
||||
NotificationType.Warning));
|
||||
return;
|
||||
}
|
||||
|
||||
var hashedCid = await GetLocalHashedCidAsync(nameof(ToggleBroadcast)).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(hashedCid))
|
||||
{
|
||||
_logger.LogWarning("ToggleBroadcast - unable to resolve CID.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false);
|
||||
var newStatus = !isCurrentlyBroadcasting;
|
||||
|
||||
if (!newStatus)
|
||||
{
|
||||
_lastForcedDisableTime = DateTime.UtcNow;
|
||||
_logger.LogDebug("Manual disable: cooldown timer started.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
|
||||
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
|
||||
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Toggle Failed",
|
||||
$"Failed to toggle broadcast: {ex.Message}",
|
||||
NotificationType.Error));
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async void OnTick(PriorityFrameworkUpdateMessage _)
|
||||
{
|
||||
if (!IsLightFinderAvailable)
|
||||
return;
|
||||
|
||||
if (_config?.Current == null)
|
||||
return;
|
||||
|
||||
if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1)
|
||||
return;
|
||||
|
||||
_lastTtlCheck = DateTime.UtcNow;
|
||||
|
||||
await RequireConnectionAsync(nameof(OnTick), async () => {
|
||||
if (!_syncedOnStartup && _config.Current.BroadcastEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hashedCid = await GetLocalHashedCidAsync("startup TTL refresh").ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(hashedCid))
|
||||
{
|
||||
_logger.LogDebug("Skipping TTL refresh; hashed CID unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
|
||||
if (TryApplyBroadcastEnabled(ttl, "startup TTL refresh"))
|
||||
{
|
||||
_syncedOnStartup = true;
|
||||
_logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", ttl);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state.");
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh TTL in OnTick");
|
||||
_syncedOnStartup = false;
|
||||
}
|
||||
}
|
||||
if (_config.Current.BroadcastEnabled)
|
||||
{
|
||||
if (_waitingForTtlFetch)
|
||||
{
|
||||
_logger.LogDebug("OnTick skipped: waiting for TTL fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime expiry = _config.Current.BroadcastTtl;
|
||||
TimeSpan remaining = expiry - DateTime.UtcNow;
|
||||
_remainingTtl = remaining > TimeSpan.Zero ? remaining : null;
|
||||
if (_remainingTtl == null)
|
||||
{
|
||||
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
ShowBroadcastExpiredNotification();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_remainingTtl = null;
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ShowBroadcastExpiredNotification()
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "broadcast_expired",
|
||||
Title = "Broadcast Expired",
|
||||
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(180),
|
||||
Actions = new List<LightlessNotificationAction>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "re_enable",
|
||||
Label = "Re-enable",
|
||||
Icon = FontAwesomeIcon.Plus,
|
||||
Color = UIColors.Get("PairBlue"),
|
||||
IsPrimary = true,
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Re-enabling broadcast from notification");
|
||||
ToggleBroadcast();
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "close",
|
||||
Label = "Close",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Broadcast expiration notification dismissed");
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ using LightlessSync.UI;
|
||||
using LightlessSync.Utils;
|
||||
using Lumina.Data.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
@@ -16,6 +20,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
private CancellationTokenSource? _analysisCts;
|
||||
private CancellationTokenSource _baseAnalysisCts = new();
|
||||
private string _lastDataHash = string.Empty;
|
||||
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
|
||||
|
||||
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
|
||||
: base(logger, mediator)
|
||||
@@ -34,6 +39,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
public bool IsAnalysisRunning => _analysisCts != null;
|
||||
public int TotalFiles { get; internal set; }
|
||||
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
|
||||
public CharacterAnalysisSummary LatestSummary => _latestSummary;
|
||||
|
||||
public void CancelAnalyze()
|
||||
{
|
||||
@@ -80,6 +86,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_analysisCts.CancelDispose();
|
||||
@@ -137,11 +145,39 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void RecalculateSummary()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
||||
|
||||
foreach (var (objectKind, entries) in LastAnalysis)
|
||||
{
|
||||
long totalTriangles = 0;
|
||||
long texOriginalBytes = 0;
|
||||
long texCompressedBytes = 0;
|
||||
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
totalTriangles += entry.Triangles;
|
||||
if (string.Equals(entry.FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
texOriginalBytes += entry.OriginalSize;
|
||||
texCompressedBytes += entry.CompressedSize;
|
||||
}
|
||||
}
|
||||
|
||||
builder[objectKind] = new CharacterAnalysisObjectSummary(entries.Count, totalTriangles, texOriginalBytes, texCompressedBytes);
|
||||
}
|
||||
|
||||
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
@@ -232,4 +268,24 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
|
||||
{
|
||||
public bool HasEntries => EntryCount > 0;
|
||||
}
|
||||
|
||||
public sealed class CharacterAnalysisSummary
|
||||
{
|
||||
public static CharacterAnalysisSummary Empty { get; } =
|
||||
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
|
||||
|
||||
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
|
||||
{
|
||||
Objects = objects;
|
||||
}
|
||||
|
||||
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
|
||||
|
||||
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
private const string _commandName = "/light";
|
||||
private const string _longName = "/lightless";
|
||||
private const string _shortName = "/light";
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ICommandManager _commandManager;
|
||||
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
|
||||
_apiController = apiController;
|
||||
_mediator = mediator;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
|
||||
_commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = $"\u2191;"
|
||||
});
|
||||
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
|
||||
"Additionally possible commands:" + Environment.NewLine +
|
||||
@@ -42,13 +47,15 @@ public sealed class CommandManagerService : IDisposable
|
||||
"\t /light toggle on|off - Connects or disconnects to Lightless respectively" + Environment.NewLine +
|
||||
"\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine +
|
||||
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
|
||||
"\t /light settings - Opens the Lightless Settings window"
|
||||
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
|
||||
"\t /light finder - Opens the Lightfinder window"
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_commandManager.RemoveHandler(_commandName);
|
||||
_commandManager.RemoveHandler(_longName);
|
||||
_commandManager.RemoveHandler(_shortName);
|
||||
}
|
||||
|
||||
private void OnCommand(string command, string args)
|
||||
@@ -122,5 +129,9 @@ public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
|
||||
}
|
||||
}
|
||||
}
|
||||
228
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal file
228
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace LightlessSync.Services.Compactor
|
||||
{
|
||||
/// <summary>
|
||||
/// This batch service is made for the File Frag command, because of each file needing to use this command.
|
||||
/// It's better to combine into one big command in batches then doing each command on each compressed call.
|
||||
/// </summary>
|
||||
public sealed partial class BatchFilefragService : IDisposable
|
||||
{
|
||||
private readonly Channel<(string path, TaskCompletionSource<bool> tcs)> _ch;
|
||||
private readonly Task _worker;
|
||||
private readonly bool _useShell;
|
||||
private readonly ILogger _log;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _flushDelay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable<string> args, string? workingDir, int timeoutMs);
|
||||
private readonly RunDirect _runDirect;
|
||||
|
||||
public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs);
|
||||
private readonly RunShell _runShell;
|
||||
|
||||
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null)
|
||||
{
|
||||
_useShell = useShell;
|
||||
_log = log;
|
||||
_batchSize = Math.Max(8, batchSize);
|
||||
_flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs));
|
||||
_ch = Channel.CreateUnbounded<(string, TaskCompletionSource<bool>)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
// require runners to be setup, wouldnt start otherwise
|
||||
if (runDirect is null || runShell is null)
|
||||
throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor");
|
||||
_runDirect = runDirect;
|
||||
_runShell = runShell;
|
||||
|
||||
_worker = Task.Run(ProcessAsync, _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the file is compressed using Btrfs using tasks
|
||||
/// </summary>
|
||||
/// <param name="linuxPath">Linux/Wine path given for the file.</param>
|
||||
/// <param name="ct">Cancellation Token</param>
|
||||
/// <returns>If it was compressed or not</returns>
|
||||
public Task<bool> IsCompressedAsync(string linuxPath, CancellationToken ct = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (!_ch.Writer.TryWrite((linuxPath, tcs)))
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
if (ct.CanBeCanceled)
|
||||
{
|
||||
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
_ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process the pending compression tasks asynchronously
|
||||
/// </summary>
|
||||
/// <returns>Task</returns>
|
||||
private async Task ProcessAsync()
|
||||
{
|
||||
var reader = _ch.Reader;
|
||||
var pending = new List<(string path, TaskCompletionSource<bool> tcs)>(_batchSize);
|
||||
|
||||
try
|
||||
{
|
||||
while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!reader.TryRead(out var first)) continue;
|
||||
pending.Add(first);
|
||||
|
||||
var flushAt = DateTime.UtcNow + _flushDelay;
|
||||
while (pending.Count < _batchSize && DateTime.UtcNow < flushAt)
|
||||
{
|
||||
if (reader.TryRead(out var item))
|
||||
{
|
||||
pending.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var map = RunBatch(pending.Select(p => p.path));
|
||||
foreach (var (path, tcs) in pending)
|
||||
{
|
||||
tcs.TrySetResult(map.TryGetValue(path, out var c) && c);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "filefrag batch failed. falling back to false");
|
||||
foreach (var (_, tcs) in pending)
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//Shutting down worker, exception called
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Running the batch of each file in the queue in one file frag command.
|
||||
/// </summary>
|
||||
/// <param name="paths">Paths that are needed for the command building for the batch return</param>
|
||||
/// <returns>Path of the file and if it went correctly</returns>
|
||||
/// <exception cref="InvalidOperationException">Failing to start filefrag on the system if this exception is found</exception>
|
||||
private Dictionary<string, bool> RunBatch(IEnumerable<string> paths)
|
||||
{
|
||||
var list = paths.Distinct(StringComparer.Ordinal).ToList();
|
||||
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
|
||||
|
||||
(bool ok, string stdout, string stderr, int code) res;
|
||||
|
||||
if (_useShell)
|
||||
{
|
||||
var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle));
|
||||
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
|
||||
}
|
||||
else
|
||||
{
|
||||
var args = new List<string> { "-v" };
|
||||
foreach (var path in list)
|
||||
{
|
||||
args.Add(' ' + path);
|
||||
}
|
||||
|
||||
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(res.stderr))
|
||||
_log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim());
|
||||
|
||||
ParseFilefrag(res.stdout, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsing the string given from the File Frag command into mapping
|
||||
/// </summary>
|
||||
/// <param name="output">Output of the process from the File Frag</param>
|
||||
/// <param name="map">Mapping of the processed files</param>
|
||||
private static void ParseFilefrag(string output, Dictionary<string, bool> map)
|
||||
{
|
||||
var reHeaderColon = ColonRegex();
|
||||
var reHeaderSize = SizeRegex();
|
||||
|
||||
string? current = null;
|
||||
using var sr = new StringReader(output);
|
||||
for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine())
|
||||
{
|
||||
var m1 = reHeaderColon.Match(line);
|
||||
if (m1.Success) { current = m1.Groups[1].Value; continue; }
|
||||
|
||||
var m2 = reHeaderSize.Match(line);
|
||||
if (m2.Success) { current = m2.Groups[1].Value; continue; }
|
||||
|
||||
if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) &&
|
||||
line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current))
|
||||
{
|
||||
map[current] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
|
||||
|
||||
/// <summary>
|
||||
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
|
||||
/// </summary>
|
||||
/// <returns>Regex of the File Size</returns>
|
||||
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)]
|
||||
private static partial Regex SizeRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Regex on colons return on the Linux/Wine systems
|
||||
/// </summary>
|
||||
/// <returns>Regex of the colons in the given path</returns>
|
||||
[GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
|
||||
private static partial Regex ColonRegex();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ch.Writer.TryComplete();
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_worker.Wait(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore the catch in dispose
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
210
LightlessSync/Services/ContextMenuService.cs
Normal file
210
LightlessSync/Services/ContextMenuService.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.Gui.ContextMenu;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
internal class ContextMenuService : IHostedService
|
||||
{
|
||||
private readonly IContextMenu _contextMenu;
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
private readonly IDataManager _gameData;
|
||||
private readonly ILogger<ContextMenuService> _logger;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly LightlessConfigService _configService;
|
||||
|
||||
public ContextMenuService(
|
||||
IContextMenu contextMenu,
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
IDataManager gameData,
|
||||
ILogger<ContextMenuService> logger,
|
||||
DalamudUtilService dalamudUtil,
|
||||
ApiController apiController,
|
||||
IObjectTable objectTable,
|
||||
LightlessConfigService configService,
|
||||
PairRequestService pairRequestService,
|
||||
PairManager pairManager,
|
||||
IClientState clientState)
|
||||
{
|
||||
_contextMenu = contextMenu;
|
||||
_pluginInterface = pluginInterface;
|
||||
_gameData = gameData;
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_apiController = apiController;
|
||||
_objectTable = objectTable;
|
||||
_configService = configService;
|
||||
_pairManager = pairManager;
|
||||
_pairRequestService = pairRequestService;
|
||||
_clientState = clientState;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_contextMenu.OnMenuOpened += OnMenuOpened;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_contextMenu.OnMenuOpened -= OnMenuOpened;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Enable()
|
||||
{
|
||||
_contextMenu.OnMenuOpened += OnMenuOpened;
|
||||
_logger.LogDebug("Context menu enabled.");
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
_contextMenu.OnMenuOpened -= OnMenuOpened;
|
||||
_logger.LogDebug("Context menu disabled.");
|
||||
}
|
||||
|
||||
private void OnMenuOpened(IMenuOpenedArgs args)
|
||||
{
|
||||
|
||||
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
|
||||
return;
|
||||
|
||||
if (args.AddonName != null)
|
||||
return;
|
||||
|
||||
//Check if target is not menutargetdefault.
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
return;
|
||||
|
||||
//Check if name or target id isnt null/zero
|
||||
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
|
||||
return;
|
||||
|
||||
//Check if it is a real target.
|
||||
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
return;
|
||||
|
||||
//Check if user is directly paired or is own.
|
||||
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
|
||||
return;
|
||||
|
||||
//Check if in PVP or GPose
|
||||
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
|
||||
return;
|
||||
|
||||
//Check for valid world.
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
return;
|
||||
|
||||
if (!_configService.Current.EnableRightClickMenus)
|
||||
return;
|
||||
|
||||
args.AddMenuItem(new MenuItem
|
||||
{
|
||||
Name = "Send Direct Pair Request",
|
||||
PrefixChar = 'L',
|
||||
UseDefaultPrefix = false,
|
||||
PrefixColor = 708,
|
||||
OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false)
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleSelection(IMenuArgs args)
|
||||
{
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
return;
|
||||
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
|
||||
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
|
||||
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
|
||||
|
||||
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
|
||||
await _apiController.TryPairWithContentId(receiverCid).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(receiverCid))
|
||||
{
|
||||
_pairRequestService.RemoveRequest(receiverCid);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending pair request.");
|
||||
}
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
|
||||
{
|
||||
return _objectTable
|
||||
.OfType<IPlayerCharacter>()
|
||||
.FirstOrDefault(p =>
|
||||
string.Equals(p.Name.TextValue, target.TargetName, StringComparison.OrdinalIgnoreCase) &&
|
||||
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
|
||||
}
|
||||
|
||||
private World GetWorld(uint worldId)
|
||||
{
|
||||
var sheet = _gameData.GetExcelSheet<World>()!;
|
||||
var luminaWorlds = sheet.Where(x =>
|
||||
{
|
||||
var dc = x.DataCenter.ValueNullable;
|
||||
var name = x.Name.ExtractText();
|
||||
var internalName = x.InternalName.ExtractText();
|
||||
|
||||
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
|
||||
return false;
|
||||
|
||||
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
|
||||
});
|
||||
|
||||
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
|
||||
}
|
||||
|
||||
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
|
||||
|
||||
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
|
||||
|
||||
public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId));
|
||||
|
||||
public static bool IsWorldValid(World world)
|
||||
{
|
||||
var name = world.Name.ToString();
|
||||
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
@@ -313,7 +313,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
|
||||
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
|
||||
{
|
||||
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
|
||||
}
|
||||
@@ -421,6 +421,16 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IPlayerCharacter? GetPlayerByNameAndWorld(string name, ushort homeWorldId)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable
|
||||
.OfType<IPlayerCharacter>()
|
||||
.FirstOrDefault(p =>
|
||||
string.Equals(p.Name.TextValue, name, StringComparison.Ordinal) &&
|
||||
p.HomeWorld.RowId == homeWorldId);
|
||||
}
|
||||
|
||||
public async Task RunOnFrameworkThread(System.Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
|
||||
@@ -531,7 +541,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
curWaitTime += tick;
|
||||
Thread.Sleep(tick);
|
||||
}
|
||||
|
||||
Thread.Sleep(tick * 2);
|
||||
}
|
||||
|
||||
@@ -547,6 +556,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return result;
|
||||
}
|
||||
|
||||
public string? GetWorldNameFromPlayerAddress(nint address)
|
||||
{
|
||||
if (address == nint.Zero) return null;
|
||||
|
||||
EnsureIsOnFramework();
|
||||
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
|
||||
if (playerCharacter == null) return null;
|
||||
|
||||
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
|
||||
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
||||
}
|
||||
|
||||
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
|
||||
{
|
||||
var gameObj = (GameObject*)address;
|
||||
|
||||
6
LightlessSync/Services/LightlessGroupProfileData.cs
Normal file
6
LightlessSync/Services/LightlessGroupProfileData.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
|
||||
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
@@ -17,6 +17,7 @@ namespace LightlessSync.Services.Mediator;
|
||||
public record SwitchToIntroUiMessage : MessageBase;
|
||||
public record SwitchToMainUiMessage : MessageBase;
|
||||
public record OpenSettingsUiMessage : MessageBase;
|
||||
public record OpenLightfinderSettingsMessage : MessageBase;
|
||||
public record DalamudLoginMessage : MessageBase;
|
||||
public record DalamudLogoutMessage : MessageBase;
|
||||
public record PriorityFrameworkUpdateMessage : SameThreadMessage;
|
||||
@@ -47,24 +48,30 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
|
||||
public record HonorificReadyMessage : MessageBase;
|
||||
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
|
||||
public record HaltScanMessage(string Source) : MessageBase;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record NotificationMessage
|
||||
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
|
||||
public record PerformanceNotificationMessage
|
||||
(string Title, string Message, UserData UserData, bool IsPaused, string PlayerName) : MessageBase;
|
||||
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
|
||||
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
|
||||
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
|
||||
public record ClearAllNotificationsMessage : MessageBase;
|
||||
public record CharacterDataAnalyzedMessage : MessageBase;
|
||||
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
|
||||
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
|
||||
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
|
||||
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
|
||||
public record UiToggleMessage(Type UiType) : MessageBase;
|
||||
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
|
||||
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase;
|
||||
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
|
||||
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
|
||||
public record CyclePauseMessage(UserData UserData) : MessageBase;
|
||||
public record PauseMessage(UserData UserData) : MessageBase;
|
||||
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
|
||||
@@ -77,6 +84,7 @@ public record OpenCensusPopupMessage() : MessageBase;
|
||||
public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
|
||||
public record OpenPermissionWindow(Pair Pair) : MessageBase;
|
||||
public record DownloadLimitChangedMessage() : SameThreadMessage;
|
||||
public record PairProcessingLimitChangedMessage : SameThreadMessage;
|
||||
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
|
||||
public record TargetPairMessage(Pair Pair) : MessageBase;
|
||||
public record CombatStartMessage : MessageBase;
|
||||
@@ -97,7 +105,12 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
|
||||
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
|
||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
||||
|
||||
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
|
||||
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
||||
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
|
||||
public record PairRequestsUpdatedMessage : MessageBase;
|
||||
public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase;
|
||||
public record VisibilityChange : MessageBase;
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services.Mediator;
|
||||
|
||||
694
LightlessSync/Services/NameplateHandler.cs
Normal file
694
LightlessSync/Services/NameplateHandler.cs
Normal file
@@ -0,0 +1,694 @@
|
||||
using Dalamud.Game.Addon.Lifecycle;
|
||||
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.UtilsEnum.Enum;
|
||||
|
||||
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public unsafe class NameplateHandler : IMediatorSubscriber
|
||||
{
|
||||
private readonly ILogger<NameplateHandler> _logger;
|
||||
private readonly IAddonLifecycle _addonLifecycle;
|
||||
private readonly IGameGui _gameGui;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly LightlessMediator _mediator;
|
||||
public LightlessMediator Mediator => _mediator;
|
||||
|
||||
private bool _mEnabled = false;
|
||||
private bool _needsLabelRefresh = false;
|
||||
private AddonNamePlate* _mpNameplateAddon = null;
|
||||
private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
internal const uint mNameplateNodeIDBase = 0x7D99D500;
|
||||
private const string DefaultLabelText = "LightFinder";
|
||||
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
|
||||
private const int _containerOffsetX = 50;
|
||||
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
|
||||
|
||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||
|
||||
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_addonLifecycle = addonLifecycle;
|
||||
_gameGui = gameGui;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_configService = configService;
|
||||
_mediator = mediator;
|
||||
_clientState = clientState;
|
||||
_pairManager = pairManager;
|
||||
|
||||
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
EnableNameplate();
|
||||
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
|
||||
}
|
||||
|
||||
internal void Uninit()
|
||||
{
|
||||
DisableNameplate();
|
||||
DestroyNameplateNodes();
|
||||
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
|
||||
_mpNameplateAddon = null;
|
||||
}
|
||||
|
||||
internal void EnableNameplate()
|
||||
{
|
||||
if (!_mEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
|
||||
_mEnabled = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}");
|
||||
DisableNameplate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void DisableNameplate()
|
||||
{
|
||||
if (_mEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
|
||||
}
|
||||
|
||||
_mEnabled = false;
|
||||
HideAllNameplateNodes();
|
||||
}
|
||||
}
|
||||
|
||||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
||||
{
|
||||
if (args.Addon.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
|
||||
return;
|
||||
}
|
||||
|
||||
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
||||
|
||||
if (_mpNameplateAddon != pNameplateAddon)
|
||||
{
|
||||
for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
|
||||
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
_mpNameplateAddon = pNameplateAddon;
|
||||
if (_mpNameplateAddon != null) CreateNameplateNodes();
|
||||
}
|
||||
|
||||
UpdateNameplateNodes();
|
||||
}
|
||||
|
||||
private void CreateNameplateNodes()
|
||||
{
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
var nameplateObject = GetNameplateObject(i);
|
||||
if (nameplateObject == null)
|
||||
continue;
|
||||
|
||||
var rootNode = nameplateObject.Value.RootComponentNode;
|
||||
if (rootNode == null || rootNode->Component == null)
|
||||
continue;
|
||||
|
||||
var pNameplateResNode = nameplateObject.Value.NameContainer;
|
||||
if (pNameplateResNode == null)
|
||||
continue;
|
||||
if (pNameplateResNode->ChildNode == null)
|
||||
continue;
|
||||
|
||||
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
|
||||
|
||||
if (pNewNode != null)
|
||||
{
|
||||
var pLastChild = pNameplateResNode->ChildNode;
|
||||
while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode;
|
||||
pNewNode->AtkResNode.NextSiblingNode = pLastChild;
|
||||
pNewNode->AtkResNode.ParentNode = pNameplateResNode;
|
||||
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
|
||||
rootNode->Component->UldManager.UpdateDrawNodeList();
|
||||
pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||
_mTextNodes[i] = pNewNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyNameplateNodes()
|
||||
{
|
||||
var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
|
||||
if (currentHandle.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
|
||||
if (_mpNameplateAddon == null)
|
||||
return;
|
||||
|
||||
if (_mpNameplateAddon != pCurrentNameplateAddon)
|
||||
{
|
||||
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
var pTextNode = _mTextNodes[i];
|
||||
var pNameplateNode = GetNameplateComponentNode(i);
|
||||
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
|
||||
{
|
||||
if (pTextNode->AtkResNode.PrevSiblingNode != null)
|
||||
pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode;
|
||||
if (pTextNode->AtkResNode.NextSiblingNode != null)
|
||||
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
|
||||
pNameplateNode->Component->UldManager.UpdateDrawNodeList();
|
||||
pTextNode->AtkResNode.Destroy(true);
|
||||
_mTextNodes[i] = null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
}
|
||||
|
||||
private void HideAllNameplateNodes()
|
||||
{
|
||||
for (int i = 0; i < _mTextNodes.Length; ++i)
|
||||
{
|
||||
HideNameplateTextNode(i);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNameplateNodes()
|
||||
{
|
||||
var currentHandle = _gameGui.GetAddonByName("NamePlate");
|
||||
if (currentHandle.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentAddon = (AddonNamePlate*)currentHandle.Address;
|
||||
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
|
||||
{
|
||||
if (_mpNameplateAddon != null)
|
||||
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
var framework = Framework.Instance();
|
||||
if (framework == null)
|
||||
{
|
||||
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var uiModule = framework->GetUIModule();
|
||||
if (uiModule == null)
|
||||
{
|
||||
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ui3DModule = uiModule->GetUI3DModule();
|
||||
if (ui3DModule == null)
|
||||
{
|
||||
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var vec = ui3DModule->NamePlateObjectInfoPointers;
|
||||
if (vec.IsEmpty)
|
||||
return;
|
||||
|
||||
var visibleUserIdsSnapshot = VisibleUserIds;
|
||||
|
||||
var safeCount = System.Math.Min(
|
||||
ui3DModule->NamePlateObjectInfoCount,
|
||||
vec.Length
|
||||
);
|
||||
|
||||
for (int i = 0; i < safeCount; ++i)
|
||||
{
|
||||
var config = _configService.Current;
|
||||
|
||||
var objectInfoPtr = vec[i];
|
||||
if (objectInfoPtr == null)
|
||||
continue;
|
||||
|
||||
var objectInfo = objectInfoPtr.Value;
|
||||
if (objectInfo == null || objectInfo->GameObject == null)
|
||||
continue;
|
||||
|
||||
var nameplateIndex = objectInfo->NamePlateIndex;
|
||||
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
|
||||
continue;
|
||||
|
||||
var pNode = _mTextNodes[nameplateIndex];
|
||||
if (pNode == null)
|
||||
continue;
|
||||
|
||||
var gameObject = objectInfo->GameObject;
|
||||
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// CID gating
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var local = _clientState.LocalPlayer;
|
||||
if (!config.LightfinderLabelShowOwn && local != null &&
|
||||
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var hidePaired = !config.LightfinderLabelShowPaired;
|
||||
|
||||
var goId = (ulong)gameObject->GetGameObjectId();
|
||||
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
|
||||
var root = nameplateObject.RootComponentNode;
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
var nameText = nameplateObject.NameText;
|
||||
var marker = nameplateObject.MarkerIcon;
|
||||
|
||||
if (root == null || root->Component == null || nameContainer == null || nameText == null)
|
||||
{
|
||||
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
|
||||
pNode->AtkResNode.ToggleVisibility(enable: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
|
||||
bool isVisible =
|
||||
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
||||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
||||
config.LightfinderLabelShowHidden;
|
||||
|
||||
pNode->AtkResNode.ToggleVisibility(isVisible);
|
||||
if (!isVisible)
|
||||
continue;
|
||||
|
||||
var labelColor = UIColors.Get("Lightfinder");
|
||||
var edgeColor = UIColors.Get("LightfinderEdge");
|
||||
|
||||
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||
var effectiveScale = baseScale * scaleMultiplier;
|
||||
var labelContent = config.LightfinderLabelUseIcon
|
||||
? NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
|
||||
: DefaultLabelText;
|
||||
|
||||
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
||||
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
|
||||
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
|
||||
var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
|
||||
pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255);
|
||||
AlignmentType alignment;
|
||||
|
||||
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||
if (textScaleY <= 0f)
|
||||
textScaleY = 1f;
|
||||
|
||||
var blockHeight = System.Math.Abs((int)nameplateObject.TextH);
|
||||
if (blockHeight > 0)
|
||||
{
|
||||
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
|
||||
}
|
||||
|
||||
if (blockHeight <= 0)
|
||||
{
|
||||
blockHeight = GetScaledTextHeight(nameText);
|
||||
if (blockHeight <= 0)
|
||||
blockHeight = nodeHeight;
|
||||
|
||||
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||
}
|
||||
|
||||
var containerHeight = (int)nameContainer->Height;
|
||||
if (containerHeight > 0)
|
||||
{
|
||||
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
|
||||
}
|
||||
|
||||
if (containerHeight <= 0)
|
||||
{
|
||||
containerHeight = blockHeight + (int)System.Math.Round(8 * textScaleY);
|
||||
if (containerHeight <= blockHeight)
|
||||
containerHeight = blockHeight + 1;
|
||||
|
||||
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||
}
|
||||
|
||||
var blockTop = containerHeight - blockHeight;
|
||||
if (blockTop < 0)
|
||||
blockTop = 0;
|
||||
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
|
||||
|
||||
var positionY = blockTop - verticalPadding - nodeHeight;
|
||||
|
||||
var textWidth = System.Math.Abs((int)nameplateObject.TextW);
|
||||
if (textWidth <= 0)
|
||||
{
|
||||
textWidth = GetScaledTextWidth(nameText);
|
||||
if (textWidth <= 0)
|
||||
textWidth = nodeWidth;
|
||||
}
|
||||
|
||||
if (textWidth > 0)
|
||||
{
|
||||
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
|
||||
}
|
||||
|
||||
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
|
||||
var hasValidOffset = true;
|
||||
|
||||
if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
|
||||
{
|
||||
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
|
||||
}
|
||||
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
|
||||
{
|
||||
textOffset = _cachedNameplateTextOffsets[nameplateIndex];
|
||||
}
|
||||
else
|
||||
{
|
||||
hasValidOffset = false;
|
||||
}
|
||||
int positionX;
|
||||
|
||||
|
||||
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||
labelContent = DefaultLabelText;
|
||||
|
||||
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
||||
|
||||
pNode->SetText(labelContent);
|
||||
|
||||
if (!config.LightfinderLabelUseIcon)
|
||||
{
|
||||
pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize;
|
||||
pNode->AtkResNode.Width = 0;
|
||||
nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
pNode->AtkResNode.Width = (ushort)nodeWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
pNode->TextFlags |= TextFlags.AutoAdjustNodeSize;
|
||||
pNode->AtkResNode.Width = 0;
|
||||
nodeWidth = pNode->AtkResNode.GetWidth();
|
||||
}
|
||||
|
||||
|
||||
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
|
||||
{
|
||||
var nameplateWidth = (int)nameContainer->Width;
|
||||
|
||||
int leftPos = nameplateWidth / 8;
|
||||
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
|
||||
int centrePos = (nameplateWidth - nodeWidth) / 2;
|
||||
int staticMargin = 24;
|
||||
int calcMargin = (int)(nameplateWidth * 0.08f);
|
||||
|
||||
switch (config.LabelAlignment)
|
||||
{
|
||||
case LabelAlignment.Left:
|
||||
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
|
||||
alignment = AlignmentType.BottomLeft;
|
||||
break;
|
||||
case LabelAlignment.Right:
|
||||
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
|
||||
alignment = AlignmentType.BottomRight;
|
||||
break;
|
||||
default:
|
||||
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
|
||||
alignment = AlignmentType.Bottom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
positionX = 58 + config.LightfinderLabelOffsetX;
|
||||
alignment = AlignmentType.Bottom;
|
||||
}
|
||||
|
||||
positionY += config.LightfinderLabelOffsetY;
|
||||
|
||||
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
|
||||
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
||||
|
||||
pNode->AtkResNode.Color.A = 255;
|
||||
|
||||
pNode->TextColor.R = (byte)(labelColor.X * 255);
|
||||
pNode->TextColor.G = (byte)(labelColor.Y * 255);
|
||||
pNode->TextColor.B = (byte)(labelColor.Z * 255);
|
||||
pNode->TextColor.A = (byte)(labelColor.W * 255);
|
||||
|
||||
pNode->EdgeColor.R = (byte)(edgeColor.X * 255);
|
||||
pNode->EdgeColor.G = (byte)(edgeColor.Y * 255);
|
||||
pNode->EdgeColor.B = (byte)(edgeColor.Z * 255);
|
||||
pNode->EdgeColor.A = (byte)(edgeColor.W * 255);
|
||||
|
||||
|
||||
if(!config.LightfinderLabelUseIcon)
|
||||
{
|
||||
pNode->AlignmentType = AlignmentType.Bottom;
|
||||
}
|
||||
else
|
||||
{
|
||||
pNode->AlignmentType = alignment;
|
||||
}
|
||||
pNode->AtkResNode.SetPositionShort(
|
||||
(short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue),
|
||||
(short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue)
|
||||
);
|
||||
var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier);
|
||||
pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue);
|
||||
pNode->CharSpacing = 1;
|
||||
pNode->TextFlags = config.LightfinderLabelUseIcon
|
||||
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
|
||||
: TextFlags.Edge | TextFlags.Glare;
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
|
||||
{
|
||||
if (node == null)
|
||||
return 0;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
var rawHeight = (int)resNode->GetHeight();
|
||||
if (rawHeight <= 0 && node->LineSpacing > 0)
|
||||
rawHeight = node->LineSpacing;
|
||||
if (rawHeight <= 0)
|
||||
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
|
||||
|
||||
var scale = resNode->ScaleY;
|
||||
if (scale <= 0f)
|
||||
scale = 1f;
|
||||
|
||||
var computed = (int)System.Math.Round(rawHeight * scale);
|
||||
return System.Math.Max(1, computed);
|
||||
}
|
||||
|
||||
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
|
||||
{
|
||||
if (node == null)
|
||||
return 0;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
var rawWidth = (int)resNode->GetWidth();
|
||||
if (rawWidth <= 0)
|
||||
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
|
||||
|
||||
var scale = resNode->ScaleX;
|
||||
if (scale <= 0f)
|
||||
scale = 1f;
|
||||
|
||||
var computed = (int)System.Math.Round(rawWidth * scale);
|
||||
return System.Math.Max(1, computed);
|
||||
}
|
||||
|
||||
internal static string NormalizeIconGlyph(string? rawInput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawInput))
|
||||
return DefaultIconGlyph;
|
||||
|
||||
var trimmed = rawInput.Trim();
|
||||
|
||||
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
|
||||
return SeIconCharExtensions.ToIconString(iconEnum);
|
||||
|
||||
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed[2..]
|
||||
: trimmed;
|
||||
|
||||
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
|
||||
return char.ConvertFromUtf32(hexValue);
|
||||
|
||||
var enumerator = trimmed.EnumerateRunes();
|
||||
if (enumerator.MoveNext())
|
||||
return enumerator.Current.ToString();
|
||||
|
||||
return DefaultIconGlyph;
|
||||
}
|
||||
|
||||
internal static string ToIconEditorString(string? rawInput)
|
||||
{
|
||||
var normalized = NormalizeIconGlyph(rawInput);
|
||||
var runeEnumerator = normalized.EnumerateRunes();
|
||||
return runeEnumerator.MoveNext()
|
||||
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
|
||||
: DefaultIconGlyph;
|
||||
}
|
||||
private void HideNameplateTextNode(int i)
|
||||
{
|
||||
var pNode = _mTextNodes[i];
|
||||
if (pNode != null)
|
||||
{
|
||||
pNode->AtkResNode.ToggleVisibility(false);
|
||||
}
|
||||
}
|
||||
|
||||
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
|
||||
{
|
||||
if (i < AddonNamePlate.NumNamePlateObjects &&
|
||||
_mpNameplateAddon != null &&
|
||||
_mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
|
||||
{
|
||||
return _mpNameplateAddon->NamePlateObjectArray[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private AtkComponentNode* GetNameplateComponentNode(int i)
|
||||
{
|
||||
var nameplateObject = GetNameplateObject(i);
|
||||
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
|
||||
public void FlagRefresh()
|
||||
{
|
||||
_needsLabelRefresh = true;
|
||||
}
|
||||
|
||||
public void OnTick(PriorityFrameworkUpdateMessage _)
|
||||
{
|
||||
if (_needsLabelRefresh)
|
||||
{
|
||||
UpdateNameplateNodes();
|
||||
_needsLabelRefresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||
{
|
||||
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
|
||||
return;
|
||||
|
||||
_activeBroadcastingCids = newSet;
|
||||
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
|
||||
FlagRefresh();
|
||||
}
|
||||
|
||||
public void ClearNameplateCaches()
|
||||
{
|
||||
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Game.Gui.NamePlate;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Plugin.Services;
|
||||
@@ -8,16 +8,17 @@ using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public class NameplateService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly ILogger<NameplateService> _logger;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly INamePlateGui _namePlateGui;
|
||||
private readonly PairManager _pairManager;
|
||||
|
||||
|
||||
public NameplateService(ILogger<NameplateService> logger,
|
||||
LightlessConfigService configService,
|
||||
INamePlateGui namePlateGui,
|
||||
@@ -25,25 +26,36 @@ public class NameplateService : DisposableMediatorSubscriberBase
|
||||
PairManager pairManager,
|
||||
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_namePlateGui = namePlateGui;
|
||||
_clientState = clientState;
|
||||
_pairManager = pairManager;
|
||||
|
||||
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
|
||||
_namePlateGui.RequestRedraw();
|
||||
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
|
||||
|
||||
}
|
||||
|
||||
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
|
||||
{
|
||||
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) return;
|
||||
var visibleUsersIds = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue).Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
|
||||
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen))
|
||||
return;
|
||||
|
||||
var visibleUsersIds = _pairManager.GetOnlineUserPairs()
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)
|
||||
.ToHashSet();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var colors = _configService.Current.NameplateColors;
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
var playerCharacter = handler.PlayerCharacter;
|
||||
if (playerCharacter == null) { continue; }
|
||||
if (playerCharacter == null)
|
||||
continue;
|
||||
|
||||
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
|
||||
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
|
||||
bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty);
|
||||
@@ -56,13 +68,25 @@ public class NameplateService : DisposableMediatorSubscriberBase
|
||||
))
|
||||
{
|
||||
handler.NameParts.TextWrap = CreateTextWrap(colors);
|
||||
|
||||
if (_configService.Current.overrideFcTagColor)
|
||||
{
|
||||
bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0;
|
||||
bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId;
|
||||
bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm);
|
||||
|
||||
if (shouldColorFcArea)
|
||||
{
|
||||
handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors);
|
||||
handler.FreeCompanyTagParts.TextWrap = CreateTextWrap(colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestRedraw()
|
||||
{
|
||||
|
||||
_namePlateGui.RequestRedraw();
|
||||
}
|
||||
|
||||
@@ -80,12 +104,11 @@ public class NameplateService : DisposableMediatorSubscriberBase
|
||||
return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString());
|
||||
}
|
||||
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
|
||||
_namePlateGui.RequestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,102 +1,432 @@
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using LightlessSync.API.Data;
|
||||
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private readonly ILogger<NotificationService> _logger;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly INotificationManager _notificationManager;
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly LightlessConfigService _configurationService;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly HashSet<string> _shownPairRequestNotifications = new();
|
||||
|
||||
public NotificationService(ILogger<NotificationService> logger, LightlessMediator mediator,
|
||||
public NotificationService(
|
||||
ILogger<NotificationService> logger,
|
||||
LightlessConfigService configService,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
INotificationManager notificationManager,
|
||||
IChatGui chatGui, LightlessConfigService configurationService) : base(logger, mediator)
|
||||
IChatGui chatGui,
|
||||
LightlessMediator mediator,
|
||||
PairRequestService pairRequestService) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_notificationManager = notificationManager;
|
||||
_chatGui = chatGui;
|
||||
_configurationService = configurationService;
|
||||
_pairRequestService = pairRequestService;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
|
||||
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
|
||||
Mediator.Subscribe<PairRequestReceivedMessage>(this, HandlePairRequestReceived);
|
||||
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
|
||||
Mediator.Subscribe<PairDownloadStatusMessage>(this, HandlePairDownloadStatus);
|
||||
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private void PrintErrorChat(string? message)
|
||||
public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info,
|
||||
TimeSpan? duration = null, List<LightlessNotificationAction>? actions = null, uint? soundEffectId = null)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message);
|
||||
_chatGui.PrintError(se.BuiltString);
|
||||
}
|
||||
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
|
||||
|
||||
private void PrintInfoChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ").AddItalics(message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintWarnChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ").AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff();
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void ShowChat(NotificationMessage msg)
|
||||
{
|
||||
switch (msg.Type)
|
||||
if (_configService.Current.AutoDismissOnAction && notification.Actions.Any())
|
||||
{
|
||||
case NotificationType.Info:
|
||||
PrintInfoChat(msg.Message);
|
||||
break;
|
||||
WrapActionsWithAutoDismiss(notification);
|
||||
}
|
||||
|
||||
case NotificationType.Warning:
|
||||
PrintWarnChat(msg.Message);
|
||||
break;
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
case NotificationType.Error:
|
||||
PrintErrorChat(msg.Message);
|
||||
break;
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
|
||||
private LightlessNotification CreateNotification(string title, string message, NotificationType type,
|
||||
TimeSpan? duration, List<LightlessNotificationAction>? actions, uint? soundEffectId)
|
||||
{
|
||||
return new LightlessNotification
|
||||
{
|
||||
Title = title,
|
||||
Message = message,
|
||||
Type = type,
|
||||
Duration = duration ?? GetDefaultDurationForType(type),
|
||||
Actions = actions ?? new List<LightlessNotificationAction>(),
|
||||
SoundEffectId = GetSoundEffectId(type, soundEffectId),
|
||||
ShowProgress = _configService.Current.ShowNotificationProgress,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private void WrapActionsWithAutoDismiss(LightlessNotification notification)
|
||||
{
|
||||
foreach (var action in notification.Actions)
|
||||
{
|
||||
var originalOnClick = action.OnClick;
|
||||
action.OnClick = (n) =>
|
||||
{
|
||||
originalOnClick(n);
|
||||
if (_configService.Current.AutoDismissOnAction)
|
||||
{
|
||||
DismissNotification(n);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNotification(NotificationMessage msg)
|
||||
private void DismissNotification(LightlessNotification notification)
|
||||
{
|
||||
Logger.LogInformation("{msg}", msg.ToString());
|
||||
notification.IsDismissed = true;
|
||||
notification.IsAnimatingOut = true;
|
||||
}
|
||||
|
||||
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
|
||||
{
|
||||
var location = GetNotificationLocation(NotificationType.PairRequest);
|
||||
|
||||
// Show in chat if configured
|
||||
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
|
||||
{
|
||||
ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
|
||||
}
|
||||
|
||||
// Show Lightless notification if configured and action buttons are enabled
|
||||
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
|
||||
&& _configService.Current.UseLightlessNotifications
|
||||
&& _configService.Current.ShowPairRequestNotificationActions)
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = $"pair_request_{senderId}",
|
||||
Title = "Pair Request Received",
|
||||
Message = $"{senderName} wants to directly pair with you.",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||
SoundEffectId = GetPairRequestSoundId(),
|
||||
Actions = CreatePairRequestActions(onAccept, onDecline)
|
||||
};
|
||||
|
||||
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() =>
|
||||
!_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null;
|
||||
|
||||
private List<LightlessNotificationAction> CreatePairRequestActions(Action onAccept, Action onDecline)
|
||||
{
|
||||
return new List<LightlessNotificationAction>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "accept",
|
||||
Label = "Accept",
|
||||
Icon = FontAwesomeIcon.Check,
|
||||
Color = UIColors.Get("LightlessGreen"),
|
||||
IsPrimary = true,
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Pair request accepted");
|
||||
onAccept();
|
||||
DismissNotification(n);
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "decline",
|
||||
Label = "Decline",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
IsDestructive = true,
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Pair request declined");
|
||||
onDecline();
|
||||
DismissNotification(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null)
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Title = "Download Complete",
|
||||
Message = FormatDownloadCompleteMessage(fileName, fileCount),
|
||||
Type = NotificationType.Info,
|
||||
Duration = TimeSpan.FromSeconds(8),
|
||||
Actions = CreateDownloadCompleteActions(onOpenFolder),
|
||||
SoundEffectId = NotificationSounds.DownloadComplete
|
||||
};
|
||||
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
|
||||
private string FormatDownloadCompleteMessage(string fileName, int fileCount) =>
|
||||
fileCount > 1
|
||||
? $"Downloaded {fileCount} files successfully."
|
||||
: $"Downloaded {fileName} successfully.";
|
||||
|
||||
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
|
||||
{
|
||||
var actions = new List<LightlessNotificationAction>();
|
||||
|
||||
if (onOpenFolder != null)
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Id = "open_folder",
|
||||
Label = "Open Folder",
|
||||
Icon = FontAwesomeIcon.FolderOpen,
|
||||
Color = UIColors.Get("LightlessBlue"),
|
||||
OnClick = (n) =>
|
||||
{
|
||||
onOpenFolder();
|
||||
DismissNotification(n);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null,
|
||||
Action? onViewLog = null)
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Title = title,
|
||||
Message = FormatErrorMessage(message, exception),
|
||||
Type = NotificationType.Error,
|
||||
Duration = TimeSpan.FromSeconds(15),
|
||||
Actions = CreateErrorActions(onRetry, onViewLog),
|
||||
SoundEffectId = NotificationSounds.Error
|
||||
};
|
||||
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
|
||||
private string FormatErrorMessage(string message, Exception? exception) =>
|
||||
exception != null ? $"{message}\n\nError: {exception.Message}" : message;
|
||||
|
||||
private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog)
|
||||
{
|
||||
var actions = new List<LightlessNotificationAction>();
|
||||
|
||||
if (onRetry != null)
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Id = "retry",
|
||||
Label = "Retry",
|
||||
Icon = FontAwesomeIcon.Redo,
|
||||
Color = UIColors.Get("LightlessBlue"),
|
||||
OnClick = (n) =>
|
||||
{
|
||||
onRetry();
|
||||
DismissNotification(n);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (onViewLog != null)
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Id = "view_log",
|
||||
Label = "View Log",
|
||||
Icon = FontAwesomeIcon.FileAlt,
|
||||
Color = UIColors.Get("LightlessYellow"),
|
||||
OnClick = (n) => onViewLog()
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
||||
private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads,
|
||||
int queueWaiting)
|
||||
{
|
||||
var messageParts = new List<string>();
|
||||
|
||||
if (queueWaiting > 0)
|
||||
{
|
||||
messageParts.Add($"Queue: {queueWaiting} waiting");
|
||||
}
|
||||
|
||||
if (userDownloads.Count > 0)
|
||||
{
|
||||
var completedCount = userDownloads.Count(x => x.Progress >= 1.0f);
|
||||
messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
|
||||
}
|
||||
|
||||
var activeDownloadLines = BuildActiveDownloadLines(userDownloads);
|
||||
if (!string.IsNullOrEmpty(activeDownloadLines))
|
||||
{
|
||||
messageParts.Add(activeDownloadLines);
|
||||
}
|
||||
|
||||
return string.Join("\n", messageParts);
|
||||
}
|
||||
|
||||
private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads)
|
||||
{
|
||||
var activeDownloads = userDownloads
|
||||
.Where(x => x.Progress < 1.0f)
|
||||
.Take(_configService.Current.MaxConcurrentPairApplications);
|
||||
|
||||
if (!activeDownloads.Any()) return string.Empty;
|
||||
|
||||
return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
|
||||
}
|
||||
|
||||
private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) =>
|
||||
download.Status switch
|
||||
{
|
||||
"downloading" => $"{download.Progress:P0}",
|
||||
"decompressing" => "decompressing",
|
||||
"queued" => "queued",
|
||||
"waiting" => "waiting for slot",
|
||||
_ => download.Status
|
||||
};
|
||||
|
||||
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
|
||||
NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds),
|
||||
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
|
||||
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
|
||||
_ => TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId)
|
||||
{
|
||||
if (overrideSoundId.HasValue) return overrideSoundId;
|
||||
if (IsSoundDisabledForType(type)) return null;
|
||||
return GetConfiguredSoundForType(type);
|
||||
}
|
||||
|
||||
private bool IsSoundDisabledForType(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => _configService.Current.DisableInfoSound,
|
||||
NotificationType.Warning => _configService.Current.DisableWarningSound,
|
||||
NotificationType.Error => _configService.Current.DisableErrorSound,
|
||||
NotificationType.Performance => _configService.Current.DisablePerformanceSound,
|
||||
NotificationType.Download => true, // Download sounds always disabled
|
||||
_ => false
|
||||
};
|
||||
|
||||
private uint GetConfiguredSoundForType(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => _configService.Current.CustomInfoSoundId,
|
||||
NotificationType.Warning => _configService.Current.CustomWarningSoundId,
|
||||
NotificationType.Error => _configService.Current.CustomErrorSoundId,
|
||||
NotificationType.Performance => _configService.Current.PerformanceSoundId,
|
||||
_ => NotificationSounds.GetDefaultSound(type)
|
||||
};
|
||||
|
||||
private void PlayNotificationSound(uint soundEffectId)
|
||||
{
|
||||
try
|
||||
{
|
||||
UIGlobals.PlayChatSoundEffect(soundEffectId);
|
||||
_logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleNotificationMessage(NotificationMessage msg)
|
||||
{
|
||||
_logger.LogInformation("{msg}", msg.ToString());
|
||||
if (!_dalamudUtilService.IsLoggedIn) return;
|
||||
|
||||
switch (msg.Type)
|
||||
{
|
||||
case NotificationType.Info:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification);
|
||||
break;
|
||||
|
||||
case NotificationType.Warning:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification);
|
||||
break;
|
||||
|
||||
case NotificationType.Error:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification);
|
||||
break;
|
||||
}
|
||||
var location = GetNotificationLocation(msg.Type);
|
||||
ShowNotificationLocationBased(msg, location);
|
||||
}
|
||||
|
||||
private NotificationLocation GetNotificationLocation(NotificationType type) =>
|
||||
_configService.Current.UseLightlessNotifications
|
||||
? GetLightlessNotificationLocation(type)
|
||||
: GetClassicNotificationLocation(type);
|
||||
|
||||
private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => _configService.Current.LightlessInfoNotification,
|
||||
NotificationType.Warning => _configService.Current.LightlessWarningNotification,
|
||||
NotificationType.Error => _configService.Current.LightlessErrorNotification,
|
||||
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
|
||||
NotificationType.Download => _configService.Current.LightlessDownloadNotification,
|
||||
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
|
||||
_ => NotificationLocation.LightlessUi
|
||||
};
|
||||
|
||||
private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => _configService.Current.InfoNotification,
|
||||
NotificationType.Warning => _configService.Current.WarningNotification,
|
||||
NotificationType.Error => _configService.Current.ErrorNotification,
|
||||
NotificationType.PairRequest => NotificationLocation.Toast,
|
||||
NotificationType.Download => NotificationLocation.Toast,
|
||||
_ => NotificationLocation.Nowhere
|
||||
};
|
||||
|
||||
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
|
||||
{
|
||||
switch (location)
|
||||
@@ -114,20 +444,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
ShowChat(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.LightlessUi:
|
||||
ShowLightlessNotification(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.ChatAndLightlessUi:
|
||||
ShowChat(msg);
|
||||
ShowLightlessNotification(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.Nowhere:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowLightlessNotification(NotificationMessage msg)
|
||||
{
|
||||
var duration = msg.TimeShownOnScreen ?? GetDefaultDurationForType(msg.Type);
|
||||
ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null);
|
||||
}
|
||||
|
||||
private void ShowToast(NotificationMessage msg)
|
||||
{
|
||||
Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch
|
||||
{
|
||||
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
|
||||
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
||||
NotificationType.Info => Dalamud.Interface.ImGuiNotification.NotificationType.Info,
|
||||
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
|
||||
};
|
||||
var dalamudType = ConvertToDalamudNotificationType(msg.Type);
|
||||
|
||||
_notificationManager.AddNotification(new Notification()
|
||||
{
|
||||
@@ -138,4 +477,269 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
||||
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
|
||||
});
|
||||
}
|
||||
|
||||
private Dalamud.Interface.ImGuiNotification.NotificationType
|
||||
ConvertToDalamudNotificationType(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
|
||||
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
||||
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
|
||||
};
|
||||
|
||||
private void ShowChat(NotificationMessage msg)
|
||||
{
|
||||
switch (msg.Type)
|
||||
{
|
||||
case NotificationType.Info:
|
||||
PrintInfoChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Warning:
|
||||
PrintWarnChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Error:
|
||||
PrintErrorChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.PairRequest:
|
||||
PrintPairRequestChat(msg.Title, msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Performance:
|
||||
PrintPerformanceChat(msg.Title, msg.Message);
|
||||
break;
|
||||
|
||||
// Download notifications don't support chat output, will be a giga spam otherwise
|
||||
case NotificationType.Download:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void PrintErrorChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message);
|
||||
_chatGui.PrintError(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintInfoChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ")
|
||||
.AddItalics(message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintWarnChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
|
||||
.AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff();
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintPairRequestChat(string? title, string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
|
||||
.AddUiForeground("Pair Request: ", 541).AddUiForegroundOff()
|
||||
.AddText(title ?? message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintPerformanceChat(string? title, string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
|
||||
.AddUiForeground("Performance: ", 508).AddUiForegroundOff()
|
||||
.AddText(title ?? message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void HandlePairRequestReceived(PairRequestReceivedMessage msg)
|
||||
{
|
||||
var request = _pairRequestService.RegisterIncomingRequest(msg.HashedCid, msg.Message);
|
||||
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName;
|
||||
|
||||
_shownPairRequestNotifications.Add(request.HashedCid);
|
||||
ShowPairRequestNotification(
|
||||
senderName,
|
||||
request.HashedCid,
|
||||
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName),
|
||||
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid, senderName));
|
||||
}
|
||||
|
||||
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
|
||||
{
|
||||
var activeRequests = _pairRequestService.GetActiveRequests();
|
||||
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet();
|
||||
|
||||
// Dismiss notifications for requests that are no longer active (expired)
|
||||
var notificationsToRemove = _shownPairRequestNotifications
|
||||
.Where(hashedCid => !activeRequestIds.Contains(hashedCid))
|
||||
.ToList();
|
||||
|
||||
foreach (var hashedCid in notificationsToRemove)
|
||||
{
|
||||
var notificationId = $"pair_request_{hashedCid}";
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage(notificationId));
|
||||
_shownPairRequestNotifications.Remove(hashedCid);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
|
||||
{
|
||||
var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList();
|
||||
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f;
|
||||
var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "pair_download_progress",
|
||||
Title = "Downloading Pair Data",
|
||||
Message = message,
|
||||
Type = NotificationType.Download,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||
ShowProgress = true,
|
||||
Progress = totalProgress
|
||||
};
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
|
||||
private void HandlePerformanceNotification(PerformanceNotificationMessage msg)
|
||||
{
|
||||
var location = GetNotificationLocation(NotificationType.Performance);
|
||||
|
||||
// Show in chat if configured
|
||||
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
|
||||
{
|
||||
ShowChat(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
|
||||
}
|
||||
|
||||
// Show Lightless notification if configured and action buttons are enabled
|
||||
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
|
||||
&& _configService.Current.UseLightlessNotifications
|
||||
&& _configService.Current.ShowPerformanceNotificationActions)
|
||||
{
|
||||
var actions = CreatePerformanceActions(msg.UserData, msg.IsPaused, msg.PlayerName);
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Title = msg.Title,
|
||||
Message = msg.Message,
|
||||
Type = NotificationType.Performance,
|
||||
Duration = TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
|
||||
Actions = actions,
|
||||
SoundEffectId = GetSoundEffectId(NotificationType.Performance, null)
|
||||
};
|
||||
|
||||
if (notification.SoundEffectId.HasValue)
|
||||
{
|
||||
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||
}
|
||||
|
||||
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
|
||||
{
|
||||
// Fall back to regular notification without action buttons
|
||||
HandleNotificationMessage(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
|
||||
}
|
||||
}
|
||||
|
||||
private List<LightlessNotificationAction> CreatePerformanceActions(UserData userData, bool isPaused, string playerName)
|
||||
{
|
||||
var actions = new List<LightlessNotificationAction>();
|
||||
|
||||
if (isPaused)
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Unpause",
|
||||
Icon = FontAwesomeIcon.Play,
|
||||
Color = UIColors.Get("LightlessGreen"),
|
||||
IsPrimary = true,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Mediator.Publish(new CyclePauseMessage(userData));
|
||||
DismissNotification(notification);
|
||||
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Player Unpaused",
|
||||
$"Successfully unpaused {displayName}",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to unpause player {uid}", userData.UID);
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Unpause Failed",
|
||||
$"Failed to unpause {displayName}",
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Pause",
|
||||
Icon = FontAwesomeIcon.Pause,
|
||||
Color = UIColors.Get("LightlessOrange"),
|
||||
IsPrimary = true,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Mediator.Publish(new PauseMessage(userData));
|
||||
DismissNotification(notification);
|
||||
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Player Paused",
|
||||
$"Successfully paused {displayName}",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pause player {uid}", userData.UID);
|
||||
var displayName = GetUserDisplayName(userData, playerName);
|
||||
ShowNotification(
|
||||
"Pause Failed",
|
||||
$"Failed to pause {displayName}",
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add dismiss button
|
||||
actions.Add(new LightlessNotificationAction
|
||||
{
|
||||
Label = "Dismiss",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
IsPrimary = false,
|
||||
OnClick = (notification) =>
|
||||
{
|
||||
DismissNotification(notification);
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private string GetUserDisplayName(UserData userData, string playerName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
|
||||
{
|
||||
return $"{playerName} ({userData.Alias})";
|
||||
}
|
||||
return $"{playerName} ({userData.UID})";
|
||||
}
|
||||
}
|
||||
255
LightlessSync/Services/PairProcessingLimiter.cs
Normal file
255
LightlessSync/Services/PairProcessingLimiter.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private const int HardLimit = 32;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly object _limitLock = new();
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private int _currentLimit;
|
||||
private int _pendingReductions;
|
||||
private int _pendingIncrements;
|
||||
private int _waiting;
|
||||
private int _inFlight;
|
||||
|
||||
public PairProcessingLimiter(ILogger<PairProcessingLimiter> logger, LightlessMediator mediator, LightlessConfigService configService)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_configService = configService;
|
||||
_currentLimit = CalculateLimit();
|
||||
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit;
|
||||
_semaphore = new SemaphoreSlim(initialCount, HardLimit);
|
||||
|
||||
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
|
||||
}
|
||||
|
||||
public ValueTask<IAsyncDisposable> AcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return WaitInternalAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public PairProcessingLimiterSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_limitLock)
|
||||
{
|
||||
var enabled = IsEnabled;
|
||||
var limit = enabled ? _currentLimit : CalculateLimit();
|
||||
var waiting = Math.Max(0, Volatile.Read(ref _waiting));
|
||||
var inFlight = Math.Max(0, Volatile.Read(ref _inFlight));
|
||||
return new PairProcessingLimiterSnapshot(enabled, limit, inFlight, waiting);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEnabled => _configService.Current.EnablePairProcessingLimiter;
|
||||
|
||||
private async ValueTask<IAsyncDisposable> WaitInternalAsync(CancellationToken token)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return NoopReleaser.Instance;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _waiting);
|
||||
try
|
||||
{
|
||||
await _semaphore.WaitAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Interlocked.Decrement(ref _waiting);
|
||||
throw;
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref _waiting);
|
||||
|
||||
if (!IsEnabled)
|
||||
{
|
||||
TryReleaseSemaphore();
|
||||
return NoopReleaser.Instance;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _inFlight);
|
||||
return new Releaser(this);
|
||||
}
|
||||
|
||||
private void UpdateSemaphoreLimit()
|
||||
{
|
||||
lock (_limitLock)
|
||||
{
|
||||
var enabled = IsEnabled;
|
||||
var desiredLimit = CalculateLimit();
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
var releaseAmount = HardLimit - _semaphore.CurrentCount;
|
||||
if (releaseAmount > 0)
|
||||
{
|
||||
TryReleaseSemaphore(releaseAmount);
|
||||
}
|
||||
|
||||
_currentLimit = desiredLimit;
|
||||
_pendingReductions = 0;
|
||||
_pendingIncrements = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (desiredLimit == _currentLimit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (desiredLimit > _currentLimit)
|
||||
{
|
||||
var increment = desiredLimit - _currentLimit;
|
||||
_pendingIncrements += increment;
|
||||
|
||||
var available = HardLimit - _semaphore.CurrentCount;
|
||||
var toRelease = Math.Min(_pendingIncrements, available);
|
||||
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
|
||||
{
|
||||
_pendingIncrements -= toRelease;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var decrement = _currentLimit - desiredLimit;
|
||||
var removed = 0;
|
||||
while (removed < decrement && _semaphore.Wait(0))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
|
||||
var remaining = decrement - removed;
|
||||
if (remaining > 0)
|
||||
{
|
||||
_pendingReductions += remaining;
|
||||
}
|
||||
|
||||
if (_pendingIncrements > 0)
|
||||
{
|
||||
var offset = Math.Min(_pendingIncrements, _pendingReductions);
|
||||
_pendingIncrements -= offset;
|
||||
_pendingReductions -= offset;
|
||||
}
|
||||
}
|
||||
|
||||
_currentLimit = desiredLimit;
|
||||
Logger.LogDebug("Pair processing concurrency updated to {limit} (pending reductions: {pending})", _currentLimit, _pendingReductions);
|
||||
}
|
||||
}
|
||||
|
||||
private int CalculateLimit()
|
||||
{
|
||||
var configured = _configService.Current.MaxConcurrentPairApplications;
|
||||
return Math.Clamp(configured, 1, HardLimit);
|
||||
}
|
||||
|
||||
private bool TryReleaseSemaphore(int count = 1)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_semaphore.Release(count);
|
||||
return true;
|
||||
}
|
||||
catch (SemaphoreFullException ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseOne()
|
||||
{
|
||||
var inFlight = Interlocked.Decrement(ref _inFlight);
|
||||
if (inFlight < 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _inFlight, 0);
|
||||
}
|
||||
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_limitLock)
|
||||
{
|
||||
if (_pendingReductions > 0)
|
||||
{
|
||||
_pendingReductions--;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingIncrements > 0)
|
||||
{
|
||||
if (!TryReleaseSemaphore())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingIncrements--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TryReleaseSemaphore();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_semaphore.Dispose();
|
||||
}
|
||||
|
||||
private sealed class Releaser : IAsyncDisposable
|
||||
{
|
||||
private PairProcessingLimiter? _owner;
|
||||
|
||||
public Releaser(PairProcessingLimiter owner)
|
||||
{
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
var owner = Interlocked.Exchange(ref _owner, null);
|
||||
owner?.ReleaseOne();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopReleaser : IAsyncDisposable
|
||||
{
|
||||
public static readonly NoopReleaser Instance = new();
|
||||
|
||||
private NoopReleaser()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
|
||||
{
|
||||
public int Remaining => Math.Max(0, Limit - InFlight);
|
||||
}
|
||||
232
LightlessSync/Services/PairRequestService.cs
Normal file
232
LightlessSync/Services/PairRequestService.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly Lazy<WebAPI.ApiController> _apiController;
|
||||
private readonly Lock _syncRoot = new();
|
||||
private readonly List<PairRequestEntry> _requests = [];
|
||||
|
||||
private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PairRequestService(
|
||||
ILogger<PairRequestService> logger,
|
||||
LightlessMediator mediator,
|
||||
DalamudUtilService dalamudUtil,
|
||||
PairManager pairManager,
|
||||
Lazy<WebAPI.ApiController> apiController)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_pairManager = pairManager;
|
||||
_apiController = apiController;
|
||||
|
||||
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
|
||||
{
|
||||
bool removed;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
removed = CleanupExpiredUnsafe();
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
Mediator.Publish(new PairRequestsUpdatedMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PairRequestDisplay RegisterIncomingRequest(string hashedCid, string messageTemplate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hashedCid))
|
||||
{
|
||||
hashedCid = string.Empty;
|
||||
}
|
||||
|
||||
messageTemplate ??= string.Empty;
|
||||
|
||||
PairRequestEntry entry = new(hashedCid, messageTemplate, DateTime.UtcNow);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
var index = _requests.FindIndex(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal));
|
||||
if (index >= 0)
|
||||
{
|
||||
_requests[index] = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
_requests.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
var display = _dalamudUtil.IsOnFrameworkThread
|
||||
? ToDisplay(entry)
|
||||
: _dalamudUtil.RunOnFrameworkThread(() => ToDisplay(entry)).GetAwaiter().GetResult();
|
||||
|
||||
Mediator.Publish(new PairRequestsUpdatedMessage());
|
||||
return display;
|
||||
}
|
||||
|
||||
public IReadOnlyList<PairRequestDisplay> GetActiveRequests()
|
||||
{
|
||||
List<PairRequestEntry> entries;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
entries = _requests
|
||||
.OrderByDescending(r => r.ReceivedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return _dalamudUtil.IsOnFrameworkThread
|
||||
? entries.Select(ToDisplay).ToList()
|
||||
: _dalamudUtil.RunOnFrameworkThread(() => entries.Select(ToDisplay).ToList()).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public bool RemoveRequest(string hashedCid)
|
||||
{
|
||||
bool removed;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0;
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
Mediator.Publish(new PairRequestsUpdatedMessage());
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public bool HasPendingRequests()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
return _requests.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private PairRequestDisplay ToDisplay(PairRequestEntry entry)
|
||||
{
|
||||
var displayName = ResolveDisplayName(entry.HashedCid);
|
||||
var message = FormatMessage(entry.MessageTemplate, displayName);
|
||||
return new PairRequestDisplay(entry.HashedCid, displayName, message, entry.ReceivedAt);
|
||||
}
|
||||
|
||||
private string ResolveDisplayName(string hashedCid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hashedCid))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid);
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var worldName = _dalamudUtil.GetWorldNameFromPlayerAddress(address);
|
||||
return !string.IsNullOrWhiteSpace(worldName)
|
||||
? $"{name} @ {worldName}"
|
||||
: name;
|
||||
}
|
||||
|
||||
var pair = _pairManager
|
||||
.GetOnlineUserPairs()
|
||||
.FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal));
|
||||
|
||||
if (pair != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.PlayerName))
|
||||
{
|
||||
return pair.PlayerName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pair.UserData.AliasOrUID))
|
||||
{
|
||||
return pair.UserData.AliasOrUID;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string FormatMessage(string template, string displayName)
|
||||
{
|
||||
var safeName = string.IsNullOrWhiteSpace(displayName) ? "Someone" : displayName;
|
||||
template ??= string.Empty;
|
||||
const string placeholder = "{DisplayName}";
|
||||
|
||||
if (!string.IsNullOrEmpty(template) && template.Contains(placeholder, StringComparison.Ordinal))
|
||||
{
|
||||
return template.Replace(placeholder, safeName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
return $"{safeName}: {template}";
|
||||
}
|
||||
|
||||
return $"{safeName} sent you a pair request.";
|
||||
}
|
||||
|
||||
private bool CleanupExpiredUnsafe()
|
||||
{
|
||||
if (_requests.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
|
||||
}
|
||||
|
||||
public void AcceptPairRequest(string hashedCid, string displayName)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _apiController.Value.TryPairWithContentId(hashedCid).ConfigureAwait(false);
|
||||
RemoveRequest(hashedCid);
|
||||
|
||||
var displayText = string.IsNullOrEmpty(displayName) ? hashedCid : displayName;
|
||||
Mediator.Publish(new NotificationMessage(
|
||||
"Pair request accepted",
|
||||
$"Sent a pair request back to {displayText}.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to accept pair request for {HashedCid}", hashedCid);
|
||||
Mediator.Publish(new NotificationMessage(
|
||||
"Failed to Accept Pair Request",
|
||||
ex.Message,
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void DeclinePairRequest(string hashedCid, string displayName)
|
||||
{
|
||||
RemoveRequest(hashedCid);
|
||||
Mediator.Publish(new NotificationMessage("Pair request declined",
|
||||
"Declined " + displayName + "'s pending pair request.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
|
||||
}
|
||||
|
||||
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
|
||||
|
||||
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt);
|
||||
}
|
||||
@@ -78,23 +78,26 @@ public class PlayerPerformanceService
|
||||
string warningText = string.Empty;
|
||||
if (exceedsTris && !exceedsVram)
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold (" +
|
||||
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
|
||||
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
|
||||
}
|
||||
else if (!exceedsTris)
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
|
||||
}
|
||||
else
|
||||
{
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB) and " +
|
||||
$"triangle warning threshold ({triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
|
||||
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
|
||||
}
|
||||
|
||||
_mediator.Publish(new NotificationMessage($"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
|
||||
warningText, LightlessConfiguration.Models.NotificationType.Warning));
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
|
||||
warningText,
|
||||
pairHandler.Pair.UserData,
|
||||
pairHandler.Pair.IsPaused,
|
||||
pairHandler.Pair.PlayerName));
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -138,11 +141,15 @@ public class PlayerPerformanceService
|
||||
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
|
||||
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold (" +
|
||||
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)" +
|
||||
$" and has been automatically paused.",
|
||||
LightlessConfiguration.Models.NotificationType.Warning));
|
||||
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
|
||||
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
|
||||
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
message,
|
||||
pair.UserData,
|
||||
true,
|
||||
pair.PlayerName));
|
||||
|
||||
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
|
||||
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
|
||||
@@ -214,11 +221,15 @@ public class PlayerPerformanceService
|
||||
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
|
||||
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB)" +
|
||||
$" and has been automatically paused.",
|
||||
LightlessConfiguration.Models.NotificationType.Warning));
|
||||
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
|
||||
|
||||
_mediator.Publish(new PerformanceNotificationMessage(
|
||||
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
|
||||
message,
|
||||
pair.UserData,
|
||||
true,
|
||||
pair.PlayerName));
|
||||
|
||||
_mediator.Publish(new PauseMessage(pair.UserData));
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ public class ServerConfigurationManager
|
||||
|
||||
internal void RenameTag(Dictionary<string, List<string>> tags, HashSet<string> storage, string oldName, string newName)
|
||||
{
|
||||
if (newName.Length > _maxCharactersFolder)
|
||||
if (newName.Length < _maxCharactersFolder)
|
||||
{
|
||||
storage.Remove(oldName);
|
||||
storage.Add(newName);
|
||||
@@ -607,8 +607,9 @@ public class ServerConfigurationManager
|
||||
{
|
||||
var baseUri = serverUri.Replace("wss://", "https://").Replace("ws://", "http://");
|
||||
var oauthCheckUri = LightlessAuth.GetUIDsFullPath(new Uri(baseUri));
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await _httpClient.GetAsync(oauthCheckUri).ConfigureAwait(false);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, oauthCheckUri);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await _httpClient.SendAsync(request).ConfigureAwait(false);
|
||||
var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(responseStream).ConfigureAwait(false) ?? [];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
@@ -18,10 +19,11 @@ public class UiFactory
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
|
||||
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
|
||||
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
|
||||
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService)
|
||||
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -31,12 +33,13 @@ public class UiFactory
|
||||
_serverConfigManager = serverConfigManager;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_fileDialogManager = fileDialogManager;
|
||||
}
|
||||
|
||||
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
|
||||
{
|
||||
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
|
||||
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
|
||||
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
|
||||
}
|
||||
|
||||
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Style;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
@@ -119,7 +120,15 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
|
||||
private void Draw()
|
||||
{
|
||||
_windowSystem.Draw();
|
||||
_fileDialogManager.Draw();
|
||||
MainStyle.PushStyle();
|
||||
try
|
||||
{
|
||||
_windowSystem.Draw();
|
||||
_fileDialogManager.Draw();
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainStyle.PopStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
437
LightlessSync/UI/BroadcastUI.cs
Normal file
437
LightlessSync/UI/BroadcastUI.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI
|
||||
{
|
||||
public class BroadcastUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
private readonly ApiController _apiController;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly BroadcastService _broadcastService;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly BroadcastScannerService _broadcastScannerService;
|
||||
|
||||
private IReadOnlyList<GroupFullInfoDto> _allSyncshells;
|
||||
private string _userUid = string.Empty;
|
||||
|
||||
private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new();
|
||||
|
||||
public BroadcastUI(
|
||||
ILogger<BroadcastUI> logger,
|
||||
LightlessMediator mediator,
|
||||
PerformanceCollectorService performanceCollectorService,
|
||||
BroadcastService broadcastService,
|
||||
LightlessConfigService configService,
|
||||
UiSharedService uiShared,
|
||||
ApiController apiController,
|
||||
BroadcastScannerService broadcastScannerService
|
||||
) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
|
||||
{
|
||||
_broadcastService = broadcastService;
|
||||
_uiSharedService = uiShared;
|
||||
_configService = configService;
|
||||
_apiController = apiController;
|
||||
_broadcastScannerService = broadcastScannerService;
|
||||
|
||||
IsOpen = false;
|
||||
this.SizeConstraints = new()
|
||||
{
|
||||
MinimumSize = new(600, 465),
|
||||
MaximumSize = new(750, 525)
|
||||
};
|
||||
}
|
||||
|
||||
private void RebuildSyncshellDropdownOptions()
|
||||
{
|
||||
var selectedGid = _configService.Current.SelectedFinderSyncshell;
|
||||
var allSyncshells = _allSyncshells ?? Array.Empty<GroupFullInfoDto>();
|
||||
var ownedSyncshells = allSyncshells
|
||||
.Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
_syncshellOptions.Clear();
|
||||
_syncshellOptions.Add(("None", null, true));
|
||||
|
||||
var addedGids = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var shell in ownedSyncshells)
|
||||
{
|
||||
var label = shell.GroupAliasOrGID ?? shell.GID;
|
||||
_syncshellOptions.Add((label, shell.GID, true));
|
||||
addedGids.Add(shell.GID);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid))
|
||||
{
|
||||
var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal));
|
||||
if (matching != null)
|
||||
{
|
||||
var label = matching.GroupAliasOrGID ?? matching.GID;
|
||||
_syncshellOptions.Add((label, matching.GID, true));
|
||||
addedGids.Add(matching.GID);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid))
|
||||
{
|
||||
_syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false));
|
||||
}
|
||||
}
|
||||
|
||||
public Task RefreshSyncshells()
|
||||
{
|
||||
return RefreshSyncshellsInternal();
|
||||
}
|
||||
|
||||
private async Task RefreshSyncshellsInternal()
|
||||
{
|
||||
if (!_apiController.IsConnected)
|
||||
{
|
||||
_allSyncshells = [];
|
||||
RebuildSyncshellDropdownOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch Syncshells.");
|
||||
_allSyncshells = [];
|
||||
}
|
||||
|
||||
RebuildSyncshellDropdownOptions();
|
||||
}
|
||||
|
||||
|
||||
public override void OnOpen()
|
||||
{
|
||||
_userUid = _apiController.UID;
|
||||
_ = RefreshSyncshells();
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
if (!_broadcastService.IsLightFinderAvailable)
|
||||
{
|
||||
_uiSharedService.MediumText("This server doesn't support Lightfinder.", UIColors.Get("LightlessYellow"));
|
||||
|
||||
ImGuiHelpers.ScaledDummy(0.25f);
|
||||
}
|
||||
|
||||
if (ImGui.BeginTabBar("##BroadcastTabs"))
|
||||
{
|
||||
if (ImGui.BeginTabItem("Lightfinder"))
|
||||
{
|
||||
_uiSharedService.MediumText("Lightfinder", UIColors.Get("PairBlue"));
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, -2));
|
||||
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users.");
|
||||
|
||||
ImGui.Indent(15f);
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
||||
ImGui.Text("- This is done using a 'Lightless' label above player nameplates.");
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.Unindent(15f);
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3f);
|
||||
|
||||
_uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue"));
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Pairing may be initiated via the right-click context menu on another player." +
|
||||
" The process requires mutual confirmation: the sender initiates the request, and the recipient completes it by responding with a request in return.");
|
||||
|
||||
_uiSharedService.DrawNoteLine(
|
||||
"! ",
|
||||
UIColors.Get("LightlessYellow"),
|
||||
new SeStringUtils.RichTextEntry("If Lightfinder is "),
|
||||
new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true),
|
||||
new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will get notified about it."));
|
||||
|
||||
_uiSharedService.DrawNoteLine(
|
||||
"! ",
|
||||
UIColors.Get("LightlessYellow"),
|
||||
new SeStringUtils.RichTextEntry("If Lightfinder is "),
|
||||
new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true),
|
||||
new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will "),
|
||||
new SeStringUtils.RichTextEntry("NOT", UIColors.Get("DimRed"), true),
|
||||
new SeStringUtils.RichTextEntry(" get a notification, and the request will not be visible to them in any way."));
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3f);
|
||||
|
||||
_uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue"));
|
||||
|
||||
_uiSharedService.DrawNoteLine(
|
||||
"! ",
|
||||
UIColors.Get("DimRed"),
|
||||
new SeStringUtils.RichTextEntry("Lightfinder is entirely "),
|
||||
new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true),
|
||||
new SeStringUtils.RichTextEntry(" and does not share any data with other users. All identifying information remains private to the server."));
|
||||
|
||||
_uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), "Pairing is intended as a mutual agreement between both parties. A pair request will not be visible to the recipient unless Lightfinder is enabled.");
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5f);
|
||||
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience.");
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
ImGui.PopStyleVar();
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3f);
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
|
||||
if (_configService.Current.BroadcastEnabled)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen"));
|
||||
ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
var ttl = _broadcastService.RemainingTtl;
|
||||
if (ttl is { } remaining && remaining > TimeSpan.Zero)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
|
||||
ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
var cooldown = _broadcastService.RemainingCooldown;
|
||||
if (cooldown is { } cd)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(0.5f);
|
||||
|
||||
bool isBroadcasting = _broadcastService.IsBroadcasting;
|
||||
bool isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0;
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
|
||||
|
||||
if (isOnCooldown)
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed"));
|
||||
else if (isBroadcasting)
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
|
||||
else
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue"));
|
||||
|
||||
if (isOnCooldown || !_broadcastService.IsLightFinderAvailable)
|
||||
ImGui.BeginDisabled();
|
||||
|
||||
string buttonText = isBroadcasting ? "Disable Lightfinder" : "Enable Lightfinder";
|
||||
|
||||
if (ImGui.Button(buttonText, new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
|
||||
{
|
||||
_broadcastService.ToggleBroadcast();
|
||||
}
|
||||
|
||||
var toggleButtonHeight = ImGui.GetItemRectSize().Y;
|
||||
|
||||
if (isOnCooldown || !_broadcastService.IsLightFinderAvailable)
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.PopStyleVar();
|
||||
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconButton(FontAwesomeIcon.Cog, toggleButtonHeight))
|
||||
{
|
||||
Mediator.Publish(new OpenLightfinderSettingsMessage());
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.TextUnformatted("Open Lightfinder settings.");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
|
||||
if (ImGui.BeginTabItem("Syncshell Finder"))
|
||||
{
|
||||
if (_allSyncshells == null)
|
||||
{
|
||||
ImGui.Text("Loading Syncshells...");
|
||||
_ = RefreshSyncshells();
|
||||
return;
|
||||
}
|
||||
|
||||
_uiSharedService.MediumText("Syncshell Finder", UIColors.Get("PairBlue"));
|
||||
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
|
||||
ImGui.PushTextWrapPos();
|
||||
ImGui.Text("Allow your owned Syncshell to be indexed by the Nearby Syncshell Finder.");
|
||||
ImGui.Text("To enable this, select one of your owned Syncshells from the dropdown menu below and ensure that \"Toggle Syncshell Finder\" is enabled. Your Syncshell will be visible in the Nearby Syncshell Finder as long as Lightfinder is active.");
|
||||
ImGui.PopTextWrapPos();
|
||||
|
||||
ImGuiHelpers.ScaledDummy(0.2f);
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
|
||||
bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled;
|
||||
bool isBroadcasting = _broadcastService.IsBroadcasting;
|
||||
|
||||
if (isBroadcasting)
|
||||
ImGui.BeginDisabled();
|
||||
|
||||
if (ImGui.Checkbox("Toggle Syncshell Finder", ref ShellFinderEnabled))
|
||||
{
|
||||
_configService.Current.SyncshellFinderEnabled = ShellFinderEnabled;
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.Text("Toggle to broadcast specified Syncshell.");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
var selectedGid = _configService.Current.SelectedFinderSyncshell;
|
||||
var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal));
|
||||
var preview = currentOption.Label ?? "Select a Syncshell...";
|
||||
|
||||
if (ImGui.BeginCombo("##SyncshellDropdown", preview))
|
||||
{
|
||||
foreach (var (label, gid, available) in _syncshellOptions)
|
||||
{
|
||||
bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal);
|
||||
|
||||
if (!available)
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
|
||||
if (ImGui.Selectable(label, isSelected))
|
||||
{
|
||||
_configService.Current.SelectedFinderSyncshell = gid;
|
||||
_configService.Save();
|
||||
_ = RefreshSyncshells();
|
||||
}
|
||||
|
||||
if (!available && ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.Text("This Syncshell is not available on the current service.");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
if (!available)
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
if (isSelected)
|
||||
ImGui.SetItemDefaultFocus();
|
||||
}
|
||||
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.Text("Choose one of the available options.");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
|
||||
if (isBroadcasting)
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if (ImGui.BeginTabItem("Debug"))
|
||||
{
|
||||
ImGui.Text("Broadcast Cache");
|
||||
|
||||
if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f)))
|
||||
{
|
||||
ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableHeadersRow();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache)
|
||||
{
|
||||
ImGui.TableNextRow();
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(cid.Truncate(12));
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.TextUnformatted(cid);
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
var colorBroadcast = entry.IsBroadcasting
|
||||
? UIColors.Get("LightlessGreen")
|
||||
: UIColors.Get("DimRed");
|
||||
|
||||
ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast));
|
||||
ImGui.TextUnformatted(entry.IsBroadcasting.ToString());
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
var remaining = entry.ExpiryTime - now;
|
||||
var colorTtl =
|
||||
remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") :
|
||||
remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") :
|
||||
(Vector4?)null;
|
||||
|
||||
if (colorTtl != null)
|
||||
ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value));
|
||||
|
||||
ImGui.TextUnformatted(remaining > TimeSpan.Zero
|
||||
? remaining.ToString("hh\\:mm\\:ss")
|
||||
: "Expired");
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(entry.GID ?? "-");
|
||||
}
|
||||
|
||||
ImGui.EndTable();
|
||||
}
|
||||
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
#endif
|
||||
|
||||
ImGui.EndTabBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
|
||||
if (!_charaDataManager.BrioAvailable)
|
||||
{
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed);
|
||||
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters, you are required to have Brio installed.", ImGuiColors.DalamudRed);
|
||||
UiSharedService.DistanceSeparator();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
@@ -15,15 +15,18 @@ using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Components;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using LightlessSync.WebAPI.Files.Models;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -51,8 +54,9 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
private readonly TopTabMenu _tabMenu;
|
||||
private readonly TagHandler _tagHandler;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly BroadcastService _broadcastService;
|
||||
|
||||
private List<IDrawFolder> _drawFolders;
|
||||
private Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>>? _cachedAnalysis;
|
||||
private Pair? _lastAddedUser;
|
||||
private string _lastAddedUserComment = string.Empty;
|
||||
private Vector2 _lastPosition = Vector2.One;
|
||||
@@ -62,13 +66,28 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
private bool _wasOpen;
|
||||
private float _windowContentWidth;
|
||||
|
||||
public CompactUi(ILogger<CompactUi> logger, UiSharedService uiShared, LightlessConfigService configService, ApiController apiController, PairManager pairManager,
|
||||
ServerConfigurationManager serverManager, LightlessMediator mediator, FileUploadManager fileTransferManager,
|
||||
TagHandler tagHandler, DrawEntityFactory drawEntityFactory,
|
||||
SelectTagForPairUi selectTagForPairUi, SelectPairForTagUi selectPairForTagUi, RenamePairTagUi renameTagUi,
|
||||
SelectTagForSyncshellUi selectTagForSyncshellUi, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi,
|
||||
PerformanceCollectorService performanceCollectorService, IpcManager ipcManager, CharacterAnalyzer characterAnalyzer, PlayerPerformanceConfigService playerPerformanceConfig, LightlessMediator lightlessMediator)
|
||||
: base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
|
||||
public CompactUi(
|
||||
ILogger<CompactUi> logger,
|
||||
UiSharedService uiShared,
|
||||
LightlessConfigService configService,
|
||||
ApiController apiController,
|
||||
PairManager pairManager,
|
||||
ServerConfigurationManager serverManager,
|
||||
LightlessMediator mediator,
|
||||
FileUploadManager fileTransferManager,
|
||||
TagHandler tagHandler,
|
||||
DrawEntityFactory drawEntityFactory,
|
||||
SelectTagForPairUi selectTagForPairUi,
|
||||
SelectPairForTagUi selectPairForTagUi,
|
||||
RenamePairTagUi renameTagUi,
|
||||
SelectTagForSyncshellUi selectTagForSyncshellUi,
|
||||
SelectSyncshellForTagUi selectSyncshellForTagUi,
|
||||
RenameSyncshellTagUi renameSyncshellTagUi,
|
||||
PerformanceCollectorService performanceCollectorService,
|
||||
IpcManager ipcManager,
|
||||
BroadcastService broadcastService,
|
||||
CharacterAnalyzer characterAnalyzer,
|
||||
PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
|
||||
{
|
||||
_uiSharedService = uiShared;
|
||||
_configService = configService;
|
||||
@@ -85,7 +104,8 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
_selectPairsForGroupUi = selectPairForTagUi;
|
||||
_renamePairTagUi = renameTagUi;
|
||||
_ipcManager = ipcManager;
|
||||
_tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService);
|
||||
_broadcastService = broadcastService;
|
||||
_tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService);
|
||||
|
||||
AllowPinning = true;
|
||||
AllowClickthrough = false;
|
||||
@@ -120,10 +140,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
ImGui.Text("Open Lightless Event Viewer");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_drawFolders = [.. GetDrawFolders()];
|
||||
_drawFolders = [.. DrawFolders];
|
||||
|
||||
#if DEBUG
|
||||
string dev = "Dev Build";
|
||||
@@ -140,7 +160,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
|
||||
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = GetDrawFolders().ToList());
|
||||
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = DrawFolders.ToList());
|
||||
|
||||
Flags |= ImGuiWindowFlags.NoDocking;
|
||||
|
||||
@@ -151,7 +171,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
};
|
||||
_characterAnalyzer = characterAnalyzer;
|
||||
_playerPerformanceConfig = playerPerformanceConfig;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_lightlessMediator = mediator;
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
@@ -202,7 +222,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
using (ImRaii.PushId("header")) DrawUIDHeader();
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
|
||||
using (ImRaii.PushId("serverstatus")) DrawServerStatus();
|
||||
ImGui.Separator();
|
||||
|
||||
@@ -357,20 +377,33 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
|
||||
private void DrawTransfers()
|
||||
{
|
||||
var currentUploads = _fileTransferManager.CurrentUploads.ToList();
|
||||
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
|
||||
ImGui.AlignTextToFramePadding();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Upload);
|
||||
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
|
||||
|
||||
if (currentUploads.Any())
|
||||
if (currentUploads.Count > 0)
|
||||
{
|
||||
var totalUploads = currentUploads.Count;
|
||||
int totalUploads = currentUploads.Count;
|
||||
int doneUploads = 0;
|
||||
long totalUploaded = 0;
|
||||
long totalToUpload = 0;
|
||||
|
||||
var doneUploads = currentUploads.Count(c => c.IsTransferred);
|
||||
var totalUploaded = currentUploads.Sum(c => c.Transferred);
|
||||
var totalToUpload = currentUploads.Sum(c => c.Total);
|
||||
foreach (var upload in currentUploads)
|
||||
{
|
||||
if (upload.IsTransferred)
|
||||
{
|
||||
doneUploads++;
|
||||
}
|
||||
|
||||
ImGui.TextUnformatted($"{doneUploads}/{totalUploads}");
|
||||
totalUploaded += upload.Transferred;
|
||||
totalToUpload += upload.Total;
|
||||
}
|
||||
|
||||
int activeUploads = totalUploads - doneUploads;
|
||||
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8);
|
||||
|
||||
ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})");
|
||||
var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})";
|
||||
var textSize = ImGui.CalcTextSize(uploadText);
|
||||
ImGui.SameLine(_windowContentWidth - textSize.X);
|
||||
@@ -383,17 +416,17 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
ImGui.TextUnformatted("No uploads in progress");
|
||||
}
|
||||
|
||||
var currentDownloads = _currentDownloads.SelectMany(d => d.Value.Values).ToList();
|
||||
var downloadSummary = GetDownloadSummary();
|
||||
ImGui.AlignTextToFramePadding();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Download);
|
||||
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
|
||||
|
||||
if (currentDownloads.Any())
|
||||
if (downloadSummary.HasDownloads)
|
||||
{
|
||||
var totalDownloads = currentDownloads.Sum(c => c.TotalFiles);
|
||||
var doneDownloads = currentDownloads.Sum(c => c.TransferredFiles);
|
||||
var totalDownloaded = currentDownloads.Sum(c => c.TransferredBytes);
|
||||
var totalToDownload = currentDownloads.Sum(c => c.TotalBytes);
|
||||
var totalDownloads = downloadSummary.TotalFiles;
|
||||
var doneDownloads = downloadSummary.TransferredFiles;
|
||||
var totalDownloaded = downloadSummary.TransferredBytes;
|
||||
var totalToDownload = downloadSummary.TotalBytes;
|
||||
|
||||
ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}");
|
||||
var downloadText =
|
||||
@@ -410,75 +443,209 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private DownloadSummary GetDownloadSummary()
|
||||
{
|
||||
long totalBytes = 0;
|
||||
long transferredBytes = 0;
|
||||
int totalFiles = 0;
|
||||
int transferredFiles = 0;
|
||||
|
||||
foreach (var kvp in _currentDownloads.ToArray())
|
||||
{
|
||||
if (kvp.Value is not { Count: > 0 } statuses)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var status in statuses.Values)
|
||||
{
|
||||
totalBytes += status.TotalBytes;
|
||||
transferredBytes += status.TransferredBytes;
|
||||
totalFiles += status.TotalFiles;
|
||||
transferredFiles += status.TransferredFiles;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
var uidText = GetUidText();
|
||||
|
||||
//Getting information of character and triangles threshold to show overlimit status in UID bar.
|
||||
_cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone();
|
||||
Vector4? vanityTextColor = null;
|
||||
Vector4? vanityGlowColor = null;
|
||||
bool useVanityColors = false;
|
||||
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
if (_configService.Current.useColoredUIDs && _apiController.HasVanity)
|
||||
{
|
||||
var uidTextSize = ImGui.CalcTextSize(uidText);
|
||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (uidTextSize.X / 2));
|
||||
ImGui.TextColored(GetUidColor(), uidText);
|
||||
if (!string.IsNullOrWhiteSpace(_apiController.TextColorHex))
|
||||
{
|
||||
vanityTextColor = UIColors.HexToRgba(_apiController.TextColorHex);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_apiController.TextGlowColorHex))
|
||||
{
|
||||
vanityGlowColor = UIColors.HexToRgba(_apiController.TextGlowColorHex);
|
||||
}
|
||||
|
||||
useVanityColors = vanityTextColor is not null || vanityGlowColor is not null;
|
||||
}
|
||||
|
||||
UiSharedService.AttachToolTip("Click to copy");
|
||||
if (ImGui.IsItemClicked())
|
||||
//Getting information of character and triangles threshold to show overlimit status in UID bar.
|
||||
var analysisSummary = _characterAnalyzer.LatestSummary;
|
||||
|
||||
Vector2 uidTextSize, iconSize;
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
uidTextSize = ImGui.CalcTextSize(uidText);
|
||||
|
||||
using (_uiSharedService.IconFont.Push())
|
||||
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
||||
|
||||
float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
|
||||
float uidStartX = (contentWidth - uidTextSize.X) / 2f;
|
||||
float cursorY = ImGui.GetCursorPosY();
|
||||
|
||||
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
|
||||
{
|
||||
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
|
||||
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
|
||||
|
||||
ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY));
|
||||
ImGui.InvisibleButton("BroadcastIcon", buttonSize);
|
||||
|
||||
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
|
||||
using (_uiSharedService.IconFont.Push())
|
||||
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
||||
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f);
|
||||
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
|
||||
ImGui.Text("Lightfinder");
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience.");
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
ImGuiHelpers.ScaledDummy(0.2f);
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
|
||||
if (_configService.Current.BroadcastEnabled)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen"));
|
||||
ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
|
||||
var ttl = _broadcastService.RemainingTtl;
|
||||
if (ttl is { } remaining && remaining > TimeSpan.Zero)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
|
||||
ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text("The Lightfinder's light wanes, but not in vain."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe..
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
var cooldown = _broadcastService.RemainingCooldown;
|
||||
if (cooldown is { } cd)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
|
||||
ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemClicked())
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
|
||||
}
|
||||
|
||||
ImGui.SetCursorPosY(cursorY);
|
||||
ImGui.SetCursorPosX(uidStartX);
|
||||
|
||||
bool headerItemClicked;
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
{
|
||||
if (useVanityColors)
|
||||
{
|
||||
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
|
||||
var cursorPos = ImGui.GetCursorScreenPos();
|
||||
var fontPtr = ImGui.GetFont();
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-header");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.TextColored(GetUidColor(), uidText);
|
||||
}
|
||||
}
|
||||
|
||||
headerItemClicked = ImGui.IsItemClicked();
|
||||
|
||||
if (headerItemClicked)
|
||||
{
|
||||
ImGui.SetClipboardText(uidText);
|
||||
}
|
||||
|
||||
if (_cachedAnalysis != null && _apiController.ServerState is ServerState.Connected)
|
||||
UiSharedService.AttachToolTip("Click to copy");
|
||||
|
||||
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
|
||||
{
|
||||
var firstEntry = _cachedAnalysis.FirstOrDefault();
|
||||
var valueDict = firstEntry.Value;
|
||||
if (valueDict != null && valueDict.Count > 0)
|
||||
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
|
||||
if (objectSummary.HasEntries)
|
||||
{
|
||||
var groupedfiles = valueDict
|
||||
.Select(v => v.Value)
|
||||
.Where(v => v != null)
|
||||
.GroupBy(f => f.FileType, StringComparer.Ordinal)
|
||||
.OrderBy(k => k.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var actualVramUsage = objectSummary.TexOriginalBytes;
|
||||
var actualTriCount = objectSummary.TotalTriangles;
|
||||
|
||||
var actualTriCount = valueDict
|
||||
.Select(v => v.Value)
|
||||
.Where(v => v != null)
|
||||
.Sum(f => f.Triangles);
|
||||
var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
|
||||
var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
|
||||
|
||||
if (groupedfiles != null)
|
||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||
{
|
||||
//Checking of VRAM threshhold
|
||||
var texGroup = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal));
|
||||
var actualVramUsage = texGroup != null ? texGroup.Sum(f => f.OriginalSize) : 0L;
|
||||
var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage;
|
||||
var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000);
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(cursorY + 15f);
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||
string warningMessage = "";
|
||||
if (isOverTriHold)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
|
||||
string warningMessage = "";
|
||||
if (isOverTriHold)
|
||||
{
|
||||
warningMessage += $"You exceed your own triangles threshold by " +
|
||||
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
||||
warningMessage += Environment.NewLine;
|
||||
warningMessage += $"You exceed your own triangles threshold by " +
|
||||
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
||||
warningMessage += Environment.NewLine;
|
||||
|
||||
}
|
||||
if (isOverVRAMUsage)
|
||||
{
|
||||
warningMessage += $"You exceed your own VRAM threshold by " +
|
||||
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
||||
}
|
||||
UiSharedService.AttachToolTip(warningMessage);
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||
}
|
||||
}
|
||||
if (isOverVRAMUsage)
|
||||
{
|
||||
warningMessage += $"You exceed your own VRAM threshold by " +
|
||||
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
||||
}
|
||||
UiSharedService.AttachToolTip(warningMessage);
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,7 +653,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
|
||||
if (_apiController.ServerState is ServerState.Connected)
|
||||
{
|
||||
if (ImGui.IsItemClicked())
|
||||
if (headerItemClicked)
|
||||
{
|
||||
ImGui.SetClipboardText(_apiController.DisplayName);
|
||||
}
|
||||
@@ -495,11 +662,24 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var origTextSize = ImGui.CalcTextSize(_apiController.UID);
|
||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
|
||||
ImGui.TextColored(GetUidColor(), _apiController.UID);
|
||||
UiSharedService.AttachToolTip("Click to copy");
|
||||
if (ImGui.IsItemClicked())
|
||||
|
||||
if (useVanityColors)
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||
var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor);
|
||||
var cursorPos = ImGui.GetCursorScreenPos();
|
||||
var fontPtr = ImGui.GetFont();
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-footer");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.TextColored(GetUidColor(), _apiController.UID);
|
||||
}
|
||||
|
||||
bool uidFooterClicked = ImGui.IsItemClicked();
|
||||
UiSharedService.AttachToolTip("Click to copy");
|
||||
if (uidFooterClicked)
|
||||
{
|
||||
ImGui.SetClipboardText(_apiController.UID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,168 +689,166 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<IDrawFolder> GetDrawFolders()
|
||||
private IEnumerable<IDrawFolder> DrawFolders
|
||||
{
|
||||
List<IDrawFolder> drawFolders = [];
|
||||
|
||||
var allPairs = _pairManager.PairsWithGroups
|
||||
.ToDictionary(k => k.Key, k => k.Value);
|
||||
var filteredPairs = allPairs
|
||||
.Where(p =>
|
||||
{
|
||||
if (_tabMenu.Filter.IsNullOrEmpty()) return true;
|
||||
return p.Key.UserData.AliasOrUID.Contains(_tabMenu.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
(p.Key.GetNote()?.Contains(_tabMenu.Filter, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(p.Key.PlayerName?.Contains(_tabMenu.Filter, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
})
|
||||
.ToDictionary(k => k.Key, k => k.Value);
|
||||
|
||||
string? AlphabeticalSort(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(u.Key.PlayerName)
|
||||
? (_configService.Current.PreferNotesOverNamesForVisible ? u.Key.GetNote() : u.Key.PlayerName)
|
||||
: (u.Key.GetNote() ?? u.Key.UserData.AliasOrUID));
|
||||
bool FilterOnlineOrPausedSelf(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> (u.Key.IsOnline || (!u.Key.IsOnline && !_configService.Current.ShowOfflineUsersSeparately)
|
||||
|| u.Key.UserPair.OwnPermissions.IsPaused());
|
||||
Dictionary<Pair, List<GroupFullInfoDto>> BasicSortedDictionary(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> u)
|
||||
=> u.OrderByDescending(u => u.Key.IsVisible)
|
||||
.ThenByDescending(u => u.Key.IsOnline)
|
||||
.ThenBy(AlphabeticalSort, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(u => u.Key, u => u.Value);
|
||||
ImmutableList<Pair> ImmutablePairList(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> u)
|
||||
=> u.Select(k => k.Key).ToImmutableList();
|
||||
bool FilterVisibleUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> u.Key.IsVisible
|
||||
&& (_configService.Current.ShowSyncshellUsersInVisible || !(!_configService.Current.ShowSyncshellUsersInVisible && !u.Key.IsDirectlyPaired));
|
||||
bool FilterTagUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u, string tag)
|
||||
=> u.Key.IsDirectlyPaired && !u.Key.IsOneSidedPair && _tagHandler.HasPairTag(u.Key.UserData.UID, tag);
|
||||
bool FilterGroupUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u, GroupFullInfoDto group)
|
||||
=> u.Value.Exists(g => string.Equals(g.GID, group.GID, StringComparison.Ordinal));
|
||||
bool FilterNotTaggedUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> u.Key.IsDirectlyPaired && !u.Key.IsOneSidedPair && !_tagHandler.HasAnyPairTag(u.Key.UserData.UID);
|
||||
bool FilterNotTaggedSyncshells(GroupFullInfoDto group)
|
||||
=> (!_tagHandler.HasAnySyncshellTag(group.GID) && !_configService.Current.ShowGroupedSyncshellsInAll) || true;
|
||||
bool FilterOfflineUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> ((u.Key.IsDirectlyPaired && _configService.Current.ShowSyncshellOfflineUsersSeparately)
|
||||
|| !_configService.Current.ShowSyncshellOfflineUsersSeparately)
|
||||
&& (!u.Key.IsOneSidedPair || u.Value.Any()) && !u.Key.IsOnline && !u.Key.UserPair.OwnPermissions.IsPaused();
|
||||
bool FilterOfflineSyncshellUsers(KeyValuePair<Pair, List<GroupFullInfoDto>> u)
|
||||
=> (!u.Key.IsDirectlyPaired && !u.Key.IsOnline && !u.Key.UserPair.OwnPermissions.IsPaused());
|
||||
|
||||
|
||||
if (_configService.Current.ShowVisibleUsersSeparately)
|
||||
get
|
||||
{
|
||||
var allVisiblePairs = ImmutablePairList(allPairs
|
||||
.Where(FilterVisibleUsers));
|
||||
var filteredVisiblePairs = BasicSortedDictionary(filteredPairs
|
||||
.Where(FilterVisibleUsers));
|
||||
var drawFolders = new List<IDrawFolder>();
|
||||
var filter = _tabMenu.Filter;
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs));
|
||||
}
|
||||
var allPairs = _pairManager.PairsWithGroups.ToDictionary(k => k.Key, k => k.Value);
|
||||
var filteredPairs = allPairs.Where(p => PassesFilter(p.Key, filter)).ToDictionary(k => k.Key, k => k.Value);
|
||||
|
||||
List<IDrawFolder> groupFolders = new();
|
||||
|
||||
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
if (FilterNotTaggedSyncshells(group))
|
||||
//Filter of online/visible pairs
|
||||
if (_configService.Current.ShowVisibleUsersSeparately)
|
||||
{
|
||||
groupFolders.Add(_drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs));
|
||||
var allVisiblePairs = ImmutablePairList(allPairs.Where(p => FilterVisibleUsers(p.Key)));
|
||||
var filteredVisiblePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterVisibleUsers(p.Key)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs));
|
||||
}
|
||||
}
|
||||
|
||||
if (_configService.Current.GroupUpSyncshells)
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
|
||||
else
|
||||
drawFolders.AddRange(groupFolders);
|
||||
|
||||
var tags = _tagHandler.GetAllPairTagsSorted();
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var allTagPairs = ImmutablePairList(allPairs
|
||||
.Where(u => FilterTagUsers(u, tag)));
|
||||
var filteredTagPairs = BasicSortedDictionary(filteredPairs
|
||||
.Where(u => FilterTagUsers(u, tag) && FilterOnlineOrPausedSelf(u)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(tag, filteredTagPairs, allTagPairs));
|
||||
}
|
||||
|
||||
var syncshellTags = _tagHandler.GetAllSyncshellTagsSorted();
|
||||
foreach (var syncshelltag in syncshellTags)
|
||||
{
|
||||
List<IDrawFolder> syncshellFolderTags = [];
|
||||
//Filter of not foldered syncshells
|
||||
var groupFolders = new List<GroupFolder>();
|
||||
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_tagHandler.HasSyncshellTag(group.GID, syncshelltag))
|
||||
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
|
||||
if (FilterNotTaggedSyncshells(group))
|
||||
{
|
||||
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
syncshellFolderTags.Add(_drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs));
|
||||
groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)));
|
||||
}
|
||||
}
|
||||
|
||||
if (syncshellFolderTags.Count > 0)
|
||||
//Filter of grouped up syncshells (All Syncshells Folder)
|
||||
if (_configService.Current.GroupUpSyncshells)
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService,
|
||||
_selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
|
||||
else
|
||||
drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder));
|
||||
|
||||
//Filter of grouped/foldered pairs
|
||||
foreach (var tag in _tagHandler.GetAllPairTagsSorted())
|
||||
{
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshelltag));
|
||||
var allTagPairs = ImmutablePairList(allPairs.Where(p => FilterTagUsers(p.Key, tag)));
|
||||
var filteredTagPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterTagUsers(p.Key, tag) && FilterOnlineOrPausedSelf(p.Key)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(tag, filteredTagPairs, allTagPairs));
|
||||
}
|
||||
}
|
||||
|
||||
var allOnlineNotTaggedPairs = ImmutablePairList(allPairs
|
||||
.Where(FilterNotTaggedUsers));
|
||||
var onlineNotTaggedPairs = BasicSortedDictionary(filteredPairs
|
||||
.Where(u => FilterNotTaggedUsers(u) && FilterOnlineOrPausedSelf(u)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder((_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag),
|
||||
onlineNotTaggedPairs, allOnlineNotTaggedPairs));
|
||||
|
||||
if (_configService.Current.ShowOfflineUsersSeparately)
|
||||
{
|
||||
var allOfflinePairs = ImmutablePairList(allPairs
|
||||
.Where(FilterOfflineUsers));
|
||||
var filteredOfflinePairs = BasicSortedDictionary(filteredPairs
|
||||
.Where(FilterOfflineUsers));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs));
|
||||
if (_configService.Current.ShowSyncshellOfflineUsersSeparately)
|
||||
//Filter of grouped/foldered syncshells
|
||||
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted())
|
||||
{
|
||||
var allOfflineSyncshellUsers = ImmutablePairList(allPairs
|
||||
.Where(FilterOfflineSyncshellUsers));
|
||||
var filteredOfflineSyncshellUsers = BasicSortedDictionary(filteredPairs
|
||||
.Where(FilterOfflineSyncshellUsers));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineSyncshellTag,
|
||||
filteredOfflineSyncshellUsers,
|
||||
allOfflineSyncshellUsers));
|
||||
}
|
||||
}
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomUnpairedTag,
|
||||
BasicSortedDictionary(filteredPairs.Where(u => u.Key.IsOneSidedPair)),
|
||||
ImmutablePairList(allPairs.Where(u => u.Key.IsOneSidedPair))));
|
||||
|
||||
return drawFolders;
|
||||
|
||||
void GetGroups(Dictionary<Pair, List<GroupFullInfoDto>> allPairs, Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs, GroupFullInfoDto group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs)
|
||||
{
|
||||
allGroupPairs = ImmutablePairList(allPairs
|
||||
.Where(u => FilterGroupUsers(u, group)));
|
||||
filteredGroupPairs = filteredPairs
|
||||
.Where(u => FilterGroupUsers(u, group) && FilterOnlineOrPausedSelf(u))
|
||||
.OrderByDescending(u => u.Key.IsOnline)
|
||||
.ThenBy(u =>
|
||||
var syncshellFolderTags = new List<GroupFolder>();
|
||||
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(u.Key.UserData.UID, group.OwnerUID, StringComparison.Ordinal)) return 0;
|
||||
if (group.GroupPairUserInfos.TryGetValue(u.Key.UserData.UID, out var info))
|
||||
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
|
||||
{
|
||||
if (info.IsModerator()) return 1;
|
||||
if (info.IsPinned()) return 2;
|
||||
GetGroups(allPairs, filteredPairs, group,
|
||||
out ImmutableList<Pair> allGroupPairs,
|
||||
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
|
||||
|
||||
syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)));
|
||||
}
|
||||
return u.Key.IsVisible ? 3 : 4;
|
||||
})
|
||||
.ThenBy(AlphabeticalSort, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(k => k.Key, k => k.Value);
|
||||
}
|
||||
|
||||
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
|
||||
}
|
||||
|
||||
//Filter of not grouped/foldered and offline pairs
|
||||
var allOnlineNotTaggedPairs = ImmutablePairList(allPairs.Where(p => FilterNotTaggedUsers(p.Key)));
|
||||
var onlineNotTaggedPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterNotTaggedUsers(p.Key) && FilterOnlineOrPausedSelf(p.Key)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder((_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag), onlineNotTaggedPairs, allOnlineNotTaggedPairs));
|
||||
|
||||
if (_configService.Current.ShowOfflineUsersSeparately)
|
||||
{
|
||||
var allOfflinePairs = ImmutablePairList(allPairs.Where(p => FilterOfflineUsers(p.Key, p.Value)));
|
||||
var filteredOfflinePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineUsers(p.Key, p.Value)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs));
|
||||
|
||||
if (_configService.Current.ShowSyncshellOfflineUsersSeparately)
|
||||
{
|
||||
var allOfflineSyncshellUsers = ImmutablePairList(allPairs.Where(p => FilterOfflineSyncshellUsers(p.Key)));
|
||||
var filteredOfflineSyncshellUsers = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineSyncshellUsers(p.Key)));
|
||||
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers));
|
||||
}
|
||||
}
|
||||
|
||||
//Unpaired
|
||||
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomUnpairedTag,
|
||||
BasicSortedDictionary(filteredPairs.Where(p => p.Key.IsOneSidedPair)),
|
||||
ImmutablePairList(allPairs.Where(p => p.Key.IsOneSidedPair))));
|
||||
|
||||
return drawFolders;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PassesFilter(Pair pair, string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter)) return true;
|
||||
|
||||
return pair.UserData.AliasOrUID.Contains(filter, StringComparison.OrdinalIgnoreCase) || (pair.GetNote()?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) || (pair.PlayerName?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
private string AlphabeticalSortKey(Pair pair)
|
||||
{
|
||||
if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(pair.PlayerName))
|
||||
{
|
||||
return _configService.Current.PreferNotesOverNamesForVisible ? (pair.GetNote() ?? string.Empty) : pair.PlayerName;
|
||||
}
|
||||
|
||||
return pair.GetNote() ?? pair.UserData.AliasOrUID;
|
||||
}
|
||||
|
||||
private bool FilterOnlineOrPausedSelf(Pair pair) => pair.IsOnline || (!pair.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || pair.UserPair.OwnPermissions.IsPaused();
|
||||
|
||||
private bool FilterVisibleUsers(Pair pair) => pair.IsVisible && (_configService.Current.ShowSyncshellUsersInVisible || pair.IsDirectlyPaired);
|
||||
|
||||
private bool FilterTagUsers(Pair pair, string tag) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && _tagHandler.HasPairTag(pair.UserData.UID, tag);
|
||||
|
||||
private static bool FilterGroupUsers(List<GroupFullInfoDto> groups, GroupFullInfoDto group) => groups.Exists(g => string.Equals(g.GID, group.GID, StringComparison.Ordinal));
|
||||
|
||||
private bool FilterNotTaggedUsers(Pair pair) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && !_tagHandler.HasAnyPairTag(pair.UserData.UID);
|
||||
|
||||
private bool FilterNotTaggedSyncshells(GroupFullInfoDto group) => !_tagHandler.HasAnySyncshellTag(group.GID) || _configService.Current.ShowGroupedSyncshellsInAll;
|
||||
|
||||
private bool FilterOfflineUsers(Pair pair, List<GroupFullInfoDto> groups) => ((pair.IsDirectlyPaired && _configService.Current.ShowSyncshellOfflineUsersSeparately) || !_configService.Current.ShowSyncshellOfflineUsersSeparately) && (!pair.IsOneSidedPair || groups.Count != 0) && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused();
|
||||
|
||||
private static bool FilterOfflineSyncshellUsers(Pair pair) => !pair.IsDirectlyPaired && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused();
|
||||
|
||||
private Dictionary<Pair, List<GroupFullInfoDto>> BasicSortedDictionary(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> pairs) => pairs.OrderByDescending(u => u.Key.IsVisible).ThenByDescending(u => u.Key.IsOnline).ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase).ToDictionary(u => u.Key, u => u.Value);
|
||||
|
||||
private static ImmutableList<Pair> ImmutablePairList(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> pairs) => [.. pairs.Select(k => k.Key)];
|
||||
|
||||
private void GetGroups(Dictionary<Pair, List<GroupFullInfoDto>> allPairs,
|
||||
Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs,
|
||||
GroupFullInfoDto group,
|
||||
out ImmutableList<Pair> allGroupPairs,
|
||||
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs)
|
||||
{
|
||||
allGroupPairs = ImmutablePairList(allPairs
|
||||
.Where(u => FilterGroupUsers(u.Value, group)));
|
||||
|
||||
filteredGroupPairs = filteredPairs
|
||||
.Where(u => FilterGroupUsers(u.Value, group) && FilterOnlineOrPausedSelf(u.Key))
|
||||
.OrderByDescending(u => u.Key.IsOnline)
|
||||
.ThenBy(u =>
|
||||
{
|
||||
if (string.Equals(u.Key.UserData.UID, group.OwnerUID, StringComparison.Ordinal)) return 0;
|
||||
if (group.GroupPairUserInfos.TryGetValue(u.Key.UserData.UID, out var info))
|
||||
{
|
||||
if (info.IsModerator()) return 1;
|
||||
if (info.IsPinned()) return 2;
|
||||
}
|
||||
return u.Key.IsVisible ? 3 : 4;
|
||||
})
|
||||
.ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(k => k.Key, k => k.Value);
|
||||
}
|
||||
|
||||
private string GetServerError()
|
||||
{
|
||||
return _apiController.ServerState switch
|
||||
@@ -748,4 +926,4 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
_wasOpen = IsOpen;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.WebAPI;
|
||||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
|
||||
@@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components;
|
||||
public class DrawGroupedGroupFolder : IDrawFolder
|
||||
{
|
||||
private readonly string _tag;
|
||||
private readonly IEnumerable<IDrawFolder> _groups;
|
||||
private readonly IEnumerable<GroupFolder> _groups;
|
||||
private readonly TagHandler _tagHandler;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
|
||||
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
|
||||
private bool _wasHovered = false;
|
||||
private float _menuWidth;
|
||||
|
||||
public IImmutableList<DrawUserPair> DrawPairs => throw new NotSupportedException();
|
||||
public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
|
||||
public int TotalPairs => _groups.Sum(g => g.TotalPairs);
|
||||
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
|
||||
public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
|
||||
public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
|
||||
|
||||
public DrawGroupedGroupFolder(IEnumerable<IDrawFolder> groups, TagHandler tagHandler, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
|
||||
public DrawGroupedGroupFolder(IEnumerable<GroupFolder> groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
|
||||
{
|
||||
_groups = groups;
|
||||
_tagHandler = tagHandler;
|
||||
@@ -30,12 +35,11 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
_selectSyncshellForTagUi = selectSyncshellForTagUi;
|
||||
_renameSyncshellTagUi = renameSyncshellTagUi;
|
||||
_tag = tag;
|
||||
_apiController = apiController;
|
||||
}
|
||||
|
||||
public void Draw()
|
||||
{
|
||||
if (!_groups.Any()) return;
|
||||
|
||||
string _id = "__folder_syncshells";
|
||||
if (_tag != "")
|
||||
{
|
||||
@@ -44,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
|
||||
using var id = ImRaii.PushId(_id);
|
||||
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
|
||||
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
|
||||
using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
|
||||
@@ -85,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
{
|
||||
ImGui.TextUnformatted(_tag);
|
||||
|
||||
ImGui.SameLine();
|
||||
DrawPauseButton();
|
||||
ImGui.SameLine();
|
||||
DrawMenu();
|
||||
} else
|
||||
{
|
||||
ImGui.TextUnformatted("All Syncshells");
|
||||
|
||||
ImGui.SameLine();
|
||||
DrawPauseButton();
|
||||
}
|
||||
}
|
||||
color.Dispose();
|
||||
@@ -102,11 +111,49 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
using var indent = ImRaii.PushIndent(20f);
|
||||
foreach (var entry in _groups)
|
||||
{
|
||||
entry.Draw();
|
||||
entry.GroupDrawFolder.Draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void DrawPauseButton()
|
||||
{
|
||||
if (DrawPairs.Count > 0)
|
||||
{
|
||||
var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
|
||||
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
|
||||
|
||||
var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon);
|
||||
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
|
||||
if (_tag != "")
|
||||
{
|
||||
var spacingX = ImGui.GetStyle().ItemSpacing.X;
|
||||
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
|
||||
ImGui.SameLine(windowEndX - pauseButtonSize.X - menuButtonSize.X - spacingX);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.SameLine(windowEndX - pauseButtonSize.X);
|
||||
}
|
||||
|
||||
|
||||
if (_uiSharedService.IconButton(pauseIcon))
|
||||
{
|
||||
ChangePauseStateGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void ChangePauseStateGroups()
|
||||
{
|
||||
foreach(var group in _groups)
|
||||
{
|
||||
var perm = group.GroupFullInfo.GroupUserPermissions;
|
||||
perm.SetPaused(!perm.IsPaused());
|
||||
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(group.GroupFullInfo.Group, new(_apiController.UID), perm));
|
||||
}
|
||||
}
|
||||
|
||||
protected void DrawMenu()
|
||||
{
|
||||
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
@@ -12,7 +12,11 @@ using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace LightlessSync.UI.Components;
|
||||
|
||||
@@ -32,6 +36,8 @@ public class DrawUserPair
|
||||
private readonly CharaDataManager _charaDataManager;
|
||||
private float _menuWidth = -1;
|
||||
private bool _wasHovered = false;
|
||||
private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty;
|
||||
private string _cachedTooltip = string.Empty;
|
||||
|
||||
public DrawUserPair(string id, Pair entry, List<GroupFullInfoDto> syncedGroups,
|
||||
GroupFullInfoDto? currentGroup,
|
||||
@@ -190,15 +196,12 @@ public class DrawUserPair
|
||||
|
||||
private void DrawLeftSide()
|
||||
{
|
||||
string userPairText = string.Empty;
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
|
||||
if (_pair.IsPaused)
|
||||
{
|
||||
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
|
||||
_uiSharedService.IconText(FontAwesomeIcon.PauseCircle);
|
||||
userPairText = _pair.UserData.AliasOrUID + " is paused";
|
||||
}
|
||||
else if (!_pair.IsOnline)
|
||||
{
|
||||
@@ -207,12 +210,10 @@ public class DrawUserPair
|
||||
? FontAwesomeIcon.ArrowsLeftRight
|
||||
: (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
|
||||
? FontAwesomeIcon.User : FontAwesomeIcon.Users));
|
||||
userPairText = _pair.UserData.AliasOrUID + " is offline";
|
||||
}
|
||||
else if (_pair.IsVisible)
|
||||
{
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue"));
|
||||
userPairText = _pair.UserData.AliasOrUID + " is visible: " + _pair.PlayerName + Environment.NewLine + "Click to target this player";
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
_mediator.Publish(new TargetPairMessage(_pair));
|
||||
@@ -223,46 +224,9 @@ public class DrawUserPair
|
||||
using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("PairBlue"));
|
||||
_uiSharedService.IconText(_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional
|
||||
? FontAwesomeIcon.User : FontAwesomeIcon.Users);
|
||||
userPairText = _pair.UserData.AliasOrUID + " is online";
|
||||
}
|
||||
|
||||
if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + "User has not added you back";
|
||||
}
|
||||
else if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + "You are directly Paired";
|
||||
}
|
||||
|
||||
if (_pair.LastAppliedDataBytes >= 0)
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator;
|
||||
userPairText += ((!_pair.IsPaired) ? "(Last) " : string.Empty) + "Mods Info" + Environment.NewLine;
|
||||
userPairText += "Files Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataBytes, true);
|
||||
if (_pair.LastAppliedApproximateVRAMBytes >= 0)
|
||||
{
|
||||
userPairText += Environment.NewLine + "Approx. VRAM Usage: " + UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes, true);
|
||||
}
|
||||
if (_pair.LastAppliedDataTris >= 0)
|
||||
{
|
||||
userPairText += Environment.NewLine + "Approx. Triangle Count (excl. Vanilla): "
|
||||
+ (_pair.LastAppliedDataTris > 1000 ? (_pair.LastAppliedDataTris / 1000d).ToString("0.0'k'") : _pair.LastAppliedDataTris);
|
||||
}
|
||||
}
|
||||
|
||||
if (_syncedGroups.Any())
|
||||
{
|
||||
userPairText += UiSharedService.TooltipSeparator + string.Join(Environment.NewLine,
|
||||
_syncedGroups.Select(g =>
|
||||
{
|
||||
var groupNote = _serverConfigurationManager.GetNoteForGid(g.GID);
|
||||
var groupString = string.IsNullOrEmpty(groupNote) ? g.GroupAliasOrGID : $"{groupNote} ({g.GroupAliasOrGID})";
|
||||
return "Paired through " + groupString;
|
||||
}));
|
||||
}
|
||||
|
||||
UiSharedService.AttachToolTip(userPairText);
|
||||
UiSharedService.AttachToolTip(GetUserTooltip());
|
||||
|
||||
if (_performanceConfigService.Current.ShowPerformanceIndicator
|
||||
&& !_performanceConfigService.Current.UIDsToIgnore
|
||||
@@ -295,6 +259,31 @@ public class DrawUserPair
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (_pair.UserData.IsAdmin || _pair.UserData.IsModerator)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
|
||||
var iconId = _pair.UserData.IsAdmin ? 67 : 68;
|
||||
var colorKey = _pair.UserData.IsAdmin ? "LightlessAdminText" : "LightlessModeratorText";
|
||||
var roleColor = UIColors.Get(colorKey);
|
||||
|
||||
var iconPos = ImGui.GetCursorScreenPos();
|
||||
SeStringUtils.RenderIconWithHitbox(iconId, iconPos);
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, roleColor))
|
||||
{
|
||||
ImGui.TextUnformatted(_pair.UserData.IsAdmin
|
||||
? "Official Lightless Developer"
|
||||
: "Official Lightless Moderator");
|
||||
}
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void DrawName(float leftSide, float rightSide)
|
||||
@@ -302,6 +291,143 @@ public class DrawUserPair
|
||||
_displayHandler.DrawPairText(_id, _pair, leftSide, () => rightSide - leftSide);
|
||||
}
|
||||
|
||||
private string GetUserTooltip()
|
||||
{
|
||||
List<string>? groupDisplays = null;
|
||||
if (_syncedGroups.Count > 0)
|
||||
{
|
||||
groupDisplays = new List<string>(_syncedGroups.Count);
|
||||
foreach (var group in _syncedGroups)
|
||||
{
|
||||
var groupNote = _serverConfigurationManager.GetNoteForGid(group.GID);
|
||||
groupDisplays.Add(string.IsNullOrEmpty(groupNote) ? group.GroupAliasOrGID : $"{groupNote} ({group.GroupAliasOrGID})");
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new TooltipSnapshot(
|
||||
_pair.IsPaused,
|
||||
_pair.IsOnline,
|
||||
_pair.IsVisible,
|
||||
_pair.IndividualPairStatus,
|
||||
_pair.UserData.AliasOrUID,
|
||||
_pair.PlayerName ?? string.Empty,
|
||||
_pair.LastAppliedDataBytes,
|
||||
_pair.LastAppliedApproximateVRAMBytes,
|
||||
_pair.LastAppliedDataTris,
|
||||
_pair.IsPaired,
|
||||
groupDisplays is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(groupDisplays));
|
||||
|
||||
if (!_tooltipSnapshot.Equals(snapshot))
|
||||
{
|
||||
_cachedTooltip = BuildTooltip(snapshot);
|
||||
_tooltipSnapshot = snapshot;
|
||||
}
|
||||
|
||||
return _cachedTooltip;
|
||||
}
|
||||
|
||||
private static string BuildTooltip(in TooltipSnapshot snapshot)
|
||||
{
|
||||
var builder = new StringBuilder(256);
|
||||
|
||||
if (snapshot.IsPaused)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is paused");
|
||||
}
|
||||
else if (!snapshot.IsOnline)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is offline");
|
||||
}
|
||||
else if (snapshot.IsVisible)
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is visible: ");
|
||||
builder.Append(snapshot.PlayerName);
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Click to target this player");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(snapshot.AliasOrUid);
|
||||
builder.Append(" is online");
|
||||
}
|
||||
|
||||
if (snapshot.PairStatus == IndividualPairStatus.OneSided)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
builder.Append("User has not added you back");
|
||||
}
|
||||
else if (snapshot.PairStatus == IndividualPairStatus.Bidirectional)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
builder.Append("You are directly Paired");
|
||||
}
|
||||
|
||||
if (snapshot.LastAppliedDataBytes >= 0)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
if (!snapshot.IsPaired)
|
||||
{
|
||||
builder.Append("(Last) ");
|
||||
}
|
||||
builder.Append("Mods Info");
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Files Size: ");
|
||||
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedDataBytes, true));
|
||||
|
||||
if (snapshot.LastAppliedApproximateVRAMBytes >= 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Approx. VRAM Usage: ");
|
||||
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true));
|
||||
}
|
||||
|
||||
if (snapshot.LastAppliedDataTris >= 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
builder.Append("Approx. Triangle Count (excl. Vanilla): ");
|
||||
builder.Append(snapshot.LastAppliedDataTris > 1000
|
||||
? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'")
|
||||
: snapshot.LastAppliedDataTris);
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot.GroupDisplays.IsEmpty)
|
||||
{
|
||||
builder.Append(UiSharedService.TooltipSeparator);
|
||||
for (int i = 0; i < snapshot.GroupDisplays.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
builder.Append(Environment.NewLine);
|
||||
}
|
||||
builder.Append("Paired through ");
|
||||
builder.Append(snapshot.GroupDisplays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private readonly record struct TooltipSnapshot(
|
||||
bool IsPaused,
|
||||
bool IsOnline,
|
||||
bool IsVisible,
|
||||
IndividualPairStatus PairStatus,
|
||||
string AliasOrUid,
|
||||
string PlayerName,
|
||||
long LastAppliedDataBytes,
|
||||
long LastAppliedApproximateVRAMBytes,
|
||||
long LastAppliedDataTris,
|
||||
bool IsPaired,
|
||||
ImmutableArray<string> GroupDisplays)
|
||||
{
|
||||
public static TooltipSnapshot Empty { get; } =
|
||||
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, false, ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
private void DrawPairedClientMenu()
|
||||
{
|
||||
DrawIndividualMenu();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace LightlessSync.UI.Components;
|
||||
|
||||
|
||||
@@ -547,73 +547,147 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
using var tab = ImRaii.TabItem(tabText + "###" + kvp.Key.ToString());
|
||||
if (tab.Success)
|
||||
{
|
||||
var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal)
|
||||
.OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
|
||||
var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal).OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
|
||||
|
||||
ImGui.TextUnformatted("Files for " + kvp.Key);
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(kvp.Value.Count.ToString());
|
||||
ImGui.SameLine();
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.CellPadding, new Vector2(1f, 1f));
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1f, 1f));
|
||||
|
||||
using (var font = ImRaii.PushFont(UiBuilder.IconFont))
|
||||
if (ImGui.BeginTable($"##fileStats_{kvp.Key}", 3,
|
||||
ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit))
|
||||
{
|
||||
ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString());
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
string text = "";
|
||||
text = string.Join(Environment.NewLine, groupedfiles
|
||||
.Select(f => f.Key + ": " + f.Count() + " files, size: " + UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))
|
||||
+ ", compressed: " + UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))));
|
||||
ImGui.SetTooltip(text);
|
||||
}
|
||||
ImGui.TextUnformatted($"{kvp.Key} size (actual):");
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.OriginalSize)));
|
||||
ImGui.TextUnformatted($"{kvp.Key} size (compressed for up/download only):");
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize)));
|
||||
ImGui.Separator();
|
||||
|
||||
var vramUsage = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal));
|
||||
if (vramUsage != null)
|
||||
{
|
||||
var actualVramUsage = vramUsage.Sum(f => f.OriginalSize);
|
||||
ImGui.TextUnformatted($"{kvp.Key} VRAM usage:");
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"Files for {kvp.Key}");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(kvp.Value.Count.ToString());
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(actualVramUsage));
|
||||
using (var font = ImRaii.PushFont(UiBuilder.IconFont))
|
||||
ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString());
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
string text = string.Join(Environment.NewLine, groupedfiles.Select(f =>
|
||||
$"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}"));
|
||||
ImGui.SetTooltip(text);
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{kvp.Key} size (actual):");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.OriginalSize)));
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{kvp.Key} size (compressed for up/download only):");
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize)));
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
var vramUsage = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal));
|
||||
if (vramUsage != null)
|
||||
{
|
||||
var actualVramUsage = vramUsage.Sum(f => f.OriginalSize);
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{kvp.Key} VRAM usage:");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(UiSharedService.ByteToString(actualVramUsage));
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
if (_playerPerformanceConfig.Current.WarnOnExceedingThresholds
|
||||
|| _playerPerformanceConfig.Current.ShowPerformanceIndicator)
|
||||
{
|
||||
var currentVramWarning = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB;
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted("Configured VRAM threshold:");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{currentVramWarning} MiB.");
|
||||
ImGui.TableNextColumn();
|
||||
if (currentVramWarning * 1024 * 1024 < actualVramUsage)
|
||||
{
|
||||
UiSharedService.ColorText(
|
||||
$"You exceed your own threshold by {UiSharedService.ByteToString(actualVramUsage - (currentVramWarning * 1024 * 1024))}",
|
||||
UIColors.Get("LightlessYellow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var actualTriCount = kvp.Value.Sum(f => f.Value.Triangles);
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{kvp.Key} modded model triangles:");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(actualTriCount.ToString());
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
if (_playerPerformanceConfig.Current.WarnOnExceedingThresholds
|
||||
|| _playerPerformanceConfig.Current.ShowPerformanceIndicator)
|
||||
{
|
||||
using var _ = ImRaii.PushIndent(10f);
|
||||
var currentVramWarning = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB;
|
||||
ImGui.TextUnformatted($"Configured VRAM warning threshold: {currentVramWarning} MiB.");
|
||||
if (currentVramWarning * 1024 * 1024 < actualVramUsage)
|
||||
var currentTriWarning = _playerPerformanceConfig.Current.TrisWarningThresholdThousands;
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted("Configured triangle threshold:");
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted($"{currentTriWarning * 1000} triangles.");
|
||||
ImGui.TableNextColumn();
|
||||
if (currentTriWarning * 1000 < actualTriCount)
|
||||
{
|
||||
UiSharedService.ColorText($"You exceed your own threshold by " +
|
||||
$"{UiSharedService.ByteToString(actualVramUsage - (currentVramWarning * 1024 * 1024))}.",
|
||||
UiSharedService.ColorText(
|
||||
$"You exceed your own threshold by {actualTriCount - (currentTriWarning * 1000)}",
|
||||
UIColors.Get("LightlessYellow"));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.EndTable();
|
||||
}
|
||||
|
||||
var actualTriCount = kvp.Value.Sum(f => f.Value.Triangles);
|
||||
ImGui.TextUnformatted($"{kvp.Key} modded model triangles: {actualTriCount}");
|
||||
if (_playerPerformanceConfig.Current.WarnOnExceedingThresholds
|
||||
|| _playerPerformanceConfig.Current.ShowPerformanceIndicator)
|
||||
ImGui.PopStyleVar(2);
|
||||
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
|
||||
|
||||
_uiSharedService.MediumText("Selected file:", UIColors.Get("LightlessBlue"));
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.MediumText(_selectedHash, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if (_cachedAnalysis[_selectedObjectTab].TryGetValue(_selectedHash, out CharacterAnalyzer.FileDataEntry? item))
|
||||
{
|
||||
using var _ = ImRaii.PushIndent(10f);
|
||||
var currentTriWarning = _playerPerformanceConfig.Current.TrisWarningThresholdThousands;
|
||||
ImGui.TextUnformatted($"Configured triangle warning threshold: {currentTriWarning * 1000} triangles.");
|
||||
if (currentTriWarning * 1000 < actualTriCount)
|
||||
var filePaths = item.FilePaths;
|
||||
UiSharedService.ColorText("Local file path:", UIColors.Get("LightlessBlue"));
|
||||
ImGui.SameLine();
|
||||
UiSharedService.TextWrapped(filePaths[0]);
|
||||
if (filePaths.Count > 1)
|
||||
{
|
||||
UiSharedService.ColorText($"You exceed your own threshold by " +
|
||||
$"{actualTriCount - (currentTriWarning * 1000)} triangles.",
|
||||
UIColors.Get("LightlessYellow"));
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted($"(and {filePaths.Count - 1} more)");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.InfoCircle);
|
||||
UiSharedService.AttachToolTip(string.Join(Environment.NewLine, filePaths.Skip(1)));
|
||||
}
|
||||
|
||||
var gamepaths = item.GamePaths;
|
||||
UiSharedService.ColorText("Used by game path:", UIColors.Get("LightlessBlue"));
|
||||
ImGui.SameLine();
|
||||
UiSharedService.TextWrapped(gamepaths[0]);
|
||||
if (gamepaths.Count > 1)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted($"(and {gamepaths.Count - 1} more)");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.InfoCircle);
|
||||
UiSharedService.AttachToolTip(string.Join(Environment.NewLine, gamepaths.Skip(1)));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
if (_selectedObjectTab != kvp.Key)
|
||||
{
|
||||
_selectedHash = string.Empty;
|
||||
@@ -692,41 +766,6 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
ImGui.TextUnformatted("Selected file:");
|
||||
ImGui.SameLine();
|
||||
UiSharedService.ColorText(_selectedHash, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if (_cachedAnalysis[_selectedObjectTab].TryGetValue(_selectedHash, out CharacterAnalyzer.FileDataEntry? item))
|
||||
{
|
||||
var filePaths = item.FilePaths;
|
||||
ImGui.TextUnformatted("Local file path:");
|
||||
ImGui.SameLine();
|
||||
UiSharedService.TextWrapped(filePaths[0]);
|
||||
if (filePaths.Count > 1)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted($"(and {filePaths.Count - 1} more)");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.InfoCircle);
|
||||
UiSharedService.AttachToolTip(string.Join(Environment.NewLine, filePaths.Skip(1)));
|
||||
}
|
||||
|
||||
var gamepaths = item.GamePaths;
|
||||
ImGui.TextUnformatted("Used by game path:");
|
||||
ImGui.SameLine();
|
||||
UiSharedService.TextWrapped(gamepaths[0]);
|
||||
if (gamepaths.Count > 1)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted($"(and {gamepaths.Count - 1} more)");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.IconText(FontAwesomeIcon.InfoCircle);
|
||||
UiSharedService.AttachToolTip(string.Join(Environment.NewLine, gamepaths.Skip(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnOpen()
|
||||
@@ -855,7 +894,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
var filePath = item.FilePaths[0];
|
||||
bool toConvert = _texturesToConvert.ContainsKey(filePath);
|
||||
if (ImGui.Checkbox("###convert" + item.Hash, ref toConvert))
|
||||
if (UiSharedService.CheckboxWithBorder("###convert" + item.Hash, ref toConvert, UIColors.Get("LightlessPurple"), 1.5f))
|
||||
{
|
||||
if (toConvert && !_texturesToConvert.ContainsKey(filePath))
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Colors;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -19,14 +20,19 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly FileUploadManager _fileTransferManager;
|
||||
private readonly UiSharedService _uiShared;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
||||
private bool _notificationDismissed = true;
|
||||
private int _lastDownloadStateHash = 0;
|
||||
|
||||
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
|
||||
FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService)
|
||||
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared,
|
||||
PerformanceCollectorService performanceCollectorService)
|
||||
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
|
||||
{
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_configService = configService;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_fileTransferManager = fileTransferManager;
|
||||
_uiShared = uiShared;
|
||||
|
||||
@@ -52,8 +58,23 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
|
||||
IsOpen = true;
|
||||
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
|
||||
{
|
||||
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
|
||||
_notificationDismissed = false;
|
||||
});
|
||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
||||
{
|
||||
_currentDownloads.TryRemove(msg.DownloadId, out _);
|
||||
|
||||
// Dismiss notification if all downloads are complete
|
||||
if (!_currentDownloads.Any() && !_notificationDismissed)
|
||||
{
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
_notificationDismissed = true;
|
||||
_lastDownloadStateHash = 0;
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = true);
|
||||
Mediator.Subscribe<PlayerUploadingMessage>(this, (msg) =>
|
||||
@@ -73,11 +94,13 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
if (_configService.Current.ShowTransferWindow)
|
||||
{
|
||||
var limiterSnapshot = _pairProcessingLimiter.GetSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (_fileTransferManager.CurrentUploads.Any())
|
||||
if (_fileTransferManager.IsUploading)
|
||||
{
|
||||
var currentUploads = _fileTransferManager.CurrentUploads.ToList();
|
||||
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
|
||||
var totalUploads = currentUploads.Count;
|
||||
|
||||
var doneUploads = currentUploads.Count(c => c.IsTransferred);
|
||||
@@ -100,38 +123,76 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown from UI
|
||||
_logger.LogDebug("Error drawing upload progress");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var item in _currentDownloads.ToList())
|
||||
// Check if download notifications are enabled (not set to TextOverlay)
|
||||
var useNotifications = _configService.Current.UseLightlessNotifications
|
||||
? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay
|
||||
: _configService.Current.UseNotificationsForDownloads;
|
||||
|
||||
if (useNotifications)
|
||||
{
|
||||
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
||||
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
||||
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
||||
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
||||
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
||||
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
||||
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
||||
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
||||
// Use notification system
|
||||
if (_currentDownloads.Any())
|
||||
{
|
||||
UpdateDownloadNotificationIfChanged(limiterSnapshot);
|
||||
_notificationDismissed = false;
|
||||
}
|
||||
else if (!_notificationDismissed)
|
||||
{
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
_notificationDismissed = true;
|
||||
_lastDownloadStateHash = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use text overlay
|
||||
if (limiterSnapshot.IsEnabled)
|
||||
{
|
||||
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
|
||||
var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}";
|
||||
queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)";
|
||||
UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.NewLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.NewLine();
|
||||
}
|
||||
|
||||
foreach (var item in _currentDownloads.ToList())
|
||||
{
|
||||
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
||||
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
||||
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
||||
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
||||
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
||||
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
||||
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
||||
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
||||
|
||||
UiSharedService.DrawOutlinedFont($"▼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.SameLine();
|
||||
var xDistance = ImGui.GetCursorPosX();
|
||||
UiSharedService.DrawOutlinedFont(
|
||||
$"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]",
|
||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.NewLine();
|
||||
ImGui.SameLine(xDistance);
|
||||
UiSharedService.DrawOutlinedFont(
|
||||
$"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})",
|
||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
UiSharedService.DrawOutlinedFont($"▼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.SameLine();
|
||||
var xDistance = ImGui.GetCursorPosX();
|
||||
UiSharedService.DrawOutlinedFont(
|
||||
$"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]",
|
||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
ImGui.NewLine();
|
||||
ImGui.SameLine(xDistance);
|
||||
UiSharedService.DrawOutlinedFont(
|
||||
$"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})",
|
||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown from UI
|
||||
_logger.LogDebug("Error drawing download progress");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +264,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore errors thrown on UI
|
||||
_logger.LogDebug("Error drawing upload progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +275,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
if (_uiShared.EditTrackerPosition) return true;
|
||||
if (!_configService.Current.ShowTransferWindow && !_configService.Current.ShowTransferBars) return false;
|
||||
if (!_currentDownloads.Any() && !_fileTransferManager.CurrentUploads.Any() && !_uploadingPlayers.Any()) return false;
|
||||
if (!_currentDownloads.Any() && !_fileTransferManager.IsUploading && !_uploadingPlayers.Any()) return false;
|
||||
if (!IsOpen) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -245,4 +306,67 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
MaximumSize = new Vector2(300, maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateDownloadNotificationIfChanged(PairProcessingLimiterSnapshot limiterSnapshot)
|
||||
{
|
||||
var downloadStatus = new List<(string playerName, float progress, string status)>(_currentDownloads.Count);
|
||||
var hashCode = new HashCode();
|
||||
|
||||
foreach (var item in _currentDownloads)
|
||||
{
|
||||
var dlSlot = 0;
|
||||
var dlQueue = 0;
|
||||
var dlProg = 0;
|
||||
var dlDecomp = 0;
|
||||
long totalBytes = 0;
|
||||
long transferredBytes = 0;
|
||||
|
||||
// Single pass through the dictionary to count everything - avoid multiple LINQ iterations
|
||||
foreach (var entry in item.Value)
|
||||
{
|
||||
var fileStatus = entry.Value;
|
||||
switch (fileStatus.DownloadStatus)
|
||||
{
|
||||
case DownloadStatus.WaitingForSlot: dlSlot++; break;
|
||||
case DownloadStatus.WaitingForQueue: dlQueue++; break;
|
||||
case DownloadStatus.Downloading: dlProg++; break;
|
||||
case DownloadStatus.Decompressing: dlDecomp++; break;
|
||||
}
|
||||
totalBytes += fileStatus.TotalBytes;
|
||||
transferredBytes += fileStatus.TransferredBytes;
|
||||
}
|
||||
|
||||
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
|
||||
|
||||
string status;
|
||||
if (dlDecomp > 0) status = "decompressing";
|
||||
else if (dlProg > 0) status = "downloading";
|
||||
else if (dlQueue > 0) status = "queued";
|
||||
else if (dlSlot > 0) status = "waiting";
|
||||
else status = "completed";
|
||||
|
||||
downloadStatus.Add((item.Key.Name, progress, status));
|
||||
|
||||
// Build hash from meaningful state
|
||||
hashCode.Add(item.Key.Name);
|
||||
hashCode.Add(transferredBytes);
|
||||
hashCode.Add(totalBytes);
|
||||
hashCode.Add(status);
|
||||
}
|
||||
|
||||
var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0;
|
||||
hashCode.Add(queueWaiting);
|
||||
|
||||
var currentHash = hashCode.ToHashCode();
|
||||
|
||||
// Only update notification if state has actually changed
|
||||
if (currentHash != _lastDownloadStateHash)
|
||||
{
|
||||
_lastDownloadStateHash = currentHash;
|
||||
if (downloadStatus.Count > 0 || queueWaiting > 0)
|
||||
{
|
||||
Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,95 @@
|
||||
using Dalamud.Game.Gui.Dtr;
|
||||
using Dalamud.Game.Gui.Dtr;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using static LightlessSync.Services.PairRequestService;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
public sealed class DtrEntry : IDisposable, IHostedService
|
||||
{
|
||||
private static readonly TimeSpan _localHashedCidCacheDuration = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan _localHashedCidErrorCooldown = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new();
|
||||
private readonly ConfigurationServiceBase<LightlessConfig> _configService;
|
||||
private readonly IDtrBar _dtrBar;
|
||||
private readonly Lazy<IDtrBarEntry> _entry;
|
||||
private readonly Lazy<IDtrBarEntry> _statusEntry;
|
||||
private readonly Lazy<IDtrBarEntry> _lightfinderEntry;
|
||||
private readonly ILogger<DtrEntry> _logger;
|
||||
private readonly BroadcastService _broadcastService;
|
||||
private readonly BroadcastScannerService _broadcastScannerService;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private Task? _runTask;
|
||||
private string? _text;
|
||||
private string? _tooltip;
|
||||
private Colors _colors;
|
||||
private string? _statusText;
|
||||
private string? _statusTooltip;
|
||||
private Colors _statusColors;
|
||||
private string? _lightfinderText;
|
||||
private string? _lightfinderTooltip;
|
||||
private Colors _lightfinderColors;
|
||||
private string? _localHashedCid;
|
||||
private DateTime _localHashedCidFetchedAt = DateTime.MinValue;
|
||||
private DateTime _localHashedCidNextErrorLog = DateTime.MinValue;
|
||||
private DateTime _pairRequestNextErrorLog = DateTime.MinValue;
|
||||
|
||||
public DtrEntry(ILogger<DtrEntry> logger, IDtrBar dtrBar, ConfigurationServiceBase<LightlessConfig> configService, LightlessMediator lightlessMediator, PairManager pairManager, ApiController apiController, ServerConfigurationManager serverManager)
|
||||
public DtrEntry(
|
||||
ILogger<DtrEntry> logger,
|
||||
IDtrBar dtrBar,
|
||||
ConfigurationServiceBase<LightlessConfig> configService,
|
||||
LightlessMediator lightlessMediator,
|
||||
PairManager pairManager,
|
||||
PairRequestService pairRequestService,
|
||||
ApiController apiController,
|
||||
ServerConfigurationManager serverManager,
|
||||
BroadcastService broadcastService,
|
||||
BroadcastScannerService broadcastScannerService,
|
||||
DalamudUtilService dalamudUtilService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dtrBar = dtrBar;
|
||||
_entry = new(CreateEntry);
|
||||
_statusEntry = new(CreateStatusEntry);
|
||||
_lightfinderEntry = new(CreateLightfinderEntry);
|
||||
_configService = configService;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_pairManager = pairManager;
|
||||
_pairRequestService = pairRequestService;
|
||||
_apiController = apiController;
|
||||
_serverManager = serverManager;
|
||||
_broadcastService = broadcastService;
|
||||
_broadcastScannerService = broadcastScannerService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_entry.IsValueCreated)
|
||||
if (_statusEntry.IsValueCreated)
|
||||
{
|
||||
_logger.LogDebug("Disposing DtrEntry");
|
||||
Clear();
|
||||
_entry.Value.Remove();
|
||||
_statusEntry.Value.Remove();
|
||||
}
|
||||
if (_lightfinderEntry.IsValueCreated)
|
||||
_lightfinderEntry.Value.Remove();
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -70,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignore cancelled
|
||||
_logger.LogInformation("Lightfinder operation was canceled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -80,33 +119,66 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
if (!_entry.IsValueCreated) return;
|
||||
_logger.LogInformation("Clearing entry");
|
||||
_text = null;
|
||||
_tooltip = null;
|
||||
_colors = default;
|
||||
|
||||
_entry.Value.Shown = false;
|
||||
HideStatusEntry();
|
||||
HideLightfinderEntry();
|
||||
}
|
||||
|
||||
private IDtrBarEntry CreateEntry()
|
||||
private void HideStatusEntry()
|
||||
{
|
||||
_logger.LogTrace("Creating new DtrBar entry");
|
||||
if (_statusEntry.IsValueCreated && _statusEntry.Value.Shown)
|
||||
{
|
||||
_logger.LogInformation("Hiding status entry");
|
||||
_statusEntry.Value.Shown = false;
|
||||
}
|
||||
|
||||
_statusText = null;
|
||||
_statusTooltip = null;
|
||||
_statusColors = default;
|
||||
}
|
||||
|
||||
private void HideLightfinderEntry()
|
||||
{
|
||||
if (_lightfinderEntry.IsValueCreated && _lightfinderEntry.Value.Shown)
|
||||
{
|
||||
_logger.LogInformation("Hiding Lightfinder entry");
|
||||
_lightfinderEntry.Value.Shown = false;
|
||||
}
|
||||
|
||||
_lightfinderText = null;
|
||||
_lightfinderTooltip = null;
|
||||
_lightfinderColors = default;
|
||||
}
|
||||
|
||||
private IDtrBarEntry CreateStatusEntry()
|
||||
{
|
||||
_logger.LogTrace("Creating status DtrBar entry");
|
||||
var entry = _dtrBar.Get("Lightless Sync");
|
||||
entry.OnClick = interactionEvent => OnClickEvent(interactionEvent);
|
||||
entry.OnClick = interactionEvent => OnStatusEntryClick(interactionEvent);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private void OnClickEvent(DtrInteractionEvent interactionEvent)
|
||||
|
||||
private IDtrBarEntry CreateLightfinderEntry()
|
||||
{
|
||||
if (interactionEvent.ClickType.Equals(MouseClickType.Left) && !interactionEvent.ModifierKeys.Equals(ClickModifierKeys.Shift))
|
||||
_logger.LogTrace("Creating Lightfinder DtrBar entry");
|
||||
var entry = _dtrBar.Get("Lightfinder");
|
||||
entry.OnClick = interactionEvent => OnLightfinderEntryClick(interactionEvent);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private void OnStatusEntryClick(DtrInteractionEvent interactionEvent)
|
||||
{
|
||||
if (interactionEvent.ClickType.Equals(MouseClickType.Left))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(CompactUi)));
|
||||
}
|
||||
else if (interactionEvent.ClickType.Equals(MouseClickType.Left) && interactionEvent.ModifierKeys.Equals(ClickModifierKeys.Shift))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
|
||||
if (interactionEvent.ModifierKeys.HasFlag(ClickModifierKeys.Shift))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
|
||||
}
|
||||
else
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(CompactUi)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactionEvent.ClickType.Equals(MouseClickType.Right))
|
||||
@@ -131,6 +203,17 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLightfinderEntryClick(DtrInteractionEvent interactionEvent)
|
||||
{
|
||||
if (!_configService.Current.ShowLightfinderInDtr)
|
||||
return;
|
||||
|
||||
if (interactionEvent.ClickType.Equals(MouseClickType.Left))
|
||||
{
|
||||
_broadcastService.ToggleBroadcast();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
while (!_cancellationTokenSource.IsCancellationRequested)
|
||||
@@ -143,96 +226,299 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_configService.Current.EnableDtrEntry || !_configService.Current.HasValidSetup())
|
||||
{
|
||||
if (_entry.IsValueCreated && _entry.Value.Shown)
|
||||
{
|
||||
_logger.LogInformation("Disabling entry");
|
||||
var config = _configService.Current;
|
||||
|
||||
Clear();
|
||||
}
|
||||
if (!config.HasValidSetup())
|
||||
{
|
||||
HideStatusEntry();
|
||||
HideLightfinderEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_entry.Value.Shown)
|
||||
{
|
||||
_logger.LogInformation("Showing entry");
|
||||
_entry.Value.Shown = true;
|
||||
}
|
||||
if (config.EnableDtrEntry)
|
||||
UpdateStatusEntry(config);
|
||||
else
|
||||
HideStatusEntry();
|
||||
|
||||
if (config.ShowLightfinderInDtr)
|
||||
UpdateLightfinderEntry(config);
|
||||
else
|
||||
HideLightfinderEntry();
|
||||
}
|
||||
|
||||
private void UpdateStatusEntry(LightlessConfig config)
|
||||
{
|
||||
string text;
|
||||
string tooltip;
|
||||
Colors colors;
|
||||
|
||||
if (_apiController.IsConnected)
|
||||
{
|
||||
var pairCount = _pairManager.GetVisibleUserCount();
|
||||
text = $"\uE044 {pairCount}";
|
||||
if (pairCount > 0)
|
||||
{
|
||||
IEnumerable<string> visiblePairs;
|
||||
if (_configService.Current.ShowUidInDtrTooltip)
|
||||
{
|
||||
visiblePairs = _pairManager.GetOnlineUserPairs()
|
||||
.Where(x => x.IsVisible)
|
||||
.Select(x => string.Format("{0} ({1})", _configService.Current.PreferNoteInDtrTooltip ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID));
|
||||
}
|
||||
else
|
||||
{
|
||||
visiblePairs = _pairManager.GetOnlineUserPairs()
|
||||
.Where(x => x.IsVisible)
|
||||
.Select(x => string.Format("{0}", _configService.Current.PreferNoteInDtrTooltip ? x.GetNote() ?? x.PlayerName : x.PlayerName));
|
||||
}
|
||||
var preferNote = config.PreferNoteInDtrTooltip;
|
||||
var showUid = config.ShowUidInDtrTooltip;
|
||||
|
||||
var visiblePairsQuery = _pairManager.GetOnlineUserPairs()
|
||||
.Where(x => x.IsVisible);
|
||||
|
||||
IEnumerable<string> visiblePairs = showUid
|
||||
? visiblePairsQuery.Select(x => string.Format("{0} ({1})", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID))
|
||||
: visiblePairsQuery.Select(x => string.Format("{0}", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName));
|
||||
|
||||
tooltip = $"Lightless Sync: Connected{Environment.NewLine}----------{Environment.NewLine}{string.Join(Environment.NewLine, visiblePairs)}";
|
||||
colors = _configService.Current.DtrColorsPairsInRange;
|
||||
colors = config.DtrColorsPairsInRange;
|
||||
}
|
||||
else
|
||||
{
|
||||
tooltip = "Lightless Sync: Connected";
|
||||
colors = _configService.Current.DtrColorsDefault;
|
||||
colors = config.DtrColorsDefault;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text = "\uE044 \uE04C";
|
||||
tooltip = "Lightless Sync: Not Connected";
|
||||
colors = _configService.Current.DtrColorsNotConnected;
|
||||
colors = config.DtrColorsNotConnected;
|
||||
}
|
||||
|
||||
if (!_configService.Current.UseColorsInDtr)
|
||||
if (!config.UseColorsInDtr)
|
||||
colors = default;
|
||||
|
||||
if (!string.Equals(text, _text, StringComparison.Ordinal) || !string.Equals(tooltip, _tooltip, StringComparison.Ordinal) || colors != _colors)
|
||||
var statusEntry = _statusEntry.Value;
|
||||
if (!statusEntry.Shown)
|
||||
{
|
||||
_text = text;
|
||||
_tooltip = tooltip;
|
||||
_colors = colors;
|
||||
_entry.Value.Text = BuildColoredSeString(text, colors);
|
||||
_entry.Value.Tooltip = tooltip;
|
||||
_logger.LogInformation("Showing status entry");
|
||||
statusEntry.Shown = true;
|
||||
}
|
||||
|
||||
bool statusNeedsUpdate =
|
||||
!string.Equals(text, _statusText, StringComparison.Ordinal) ||
|
||||
!string.Equals(tooltip, _statusTooltip, StringComparison.Ordinal) ||
|
||||
colors != _statusColors;
|
||||
|
||||
if (statusNeedsUpdate)
|
||||
{
|
||||
statusEntry.Text = BuildColoredSeString(text, colors);
|
||||
statusEntry.Tooltip = tooltip;
|
||||
_statusText = text;
|
||||
_statusTooltip = tooltip;
|
||||
_statusColors = colors;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLightfinderEntry(LightlessConfig config)
|
||||
{
|
||||
var lightfinderEntry = _lightfinderEntry.Value;
|
||||
if (!lightfinderEntry.Shown)
|
||||
{
|
||||
_logger.LogInformation("Showing Lightfinder entry");
|
||||
lightfinderEntry.Shown = true;
|
||||
}
|
||||
|
||||
var indicator = BuildLightfinderIndicator();
|
||||
var lightfinderText = indicator.Text ?? string.Empty;
|
||||
var lightfinderColors = config.UseLightfinderColorsInDtr ? indicator.Colors : default;
|
||||
var lightfinderTooltip = BuildLightfinderTooltip(indicator.Tooltip);
|
||||
|
||||
bool lightfinderNeedsUpdate =
|
||||
!string.Equals(lightfinderText, _lightfinderText, StringComparison.Ordinal) ||
|
||||
!string.Equals(lightfinderTooltip, _lightfinderTooltip, StringComparison.Ordinal) ||
|
||||
lightfinderColors != _lightfinderColors;
|
||||
|
||||
if (lightfinderNeedsUpdate)
|
||||
{
|
||||
lightfinderEntry.Text = BuildColoredSeString(lightfinderText, lightfinderColors);
|
||||
lightfinderEntry.Tooltip = lightfinderTooltip;
|
||||
_lightfinderText = lightfinderText;
|
||||
_lightfinderTooltip = lightfinderTooltip;
|
||||
_lightfinderColors = lightfinderColors;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetLocalHashedCid()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
|
||||
return _localHashedCid;
|
||||
|
||||
try
|
||||
{
|
||||
var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult();
|
||||
var hashedCid = cid.ToString().GetHash256();
|
||||
_localHashedCid = hashedCid;
|
||||
_localHashedCidFetchedAt = now;
|
||||
return hashedCid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (now >= _localHashedCidNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry.");
|
||||
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
_localHashedCid = null;
|
||||
_localHashedCidFetchedAt = now;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> GetNearbyBroadcasts()
|
||||
{
|
||||
try
|
||||
{
|
||||
var localHashedCid = GetLocalHashedCid();
|
||||
return [.. _broadcastScannerService
|
||||
.GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid)
|
||||
.Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (now >= _pairRequestNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry.");
|
||||
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PairRequestDisplay> GetPendingPairRequest()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _pairRequestService.GetActiveRequests();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (now >= _pairRequestNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry.");
|
||||
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private (string Text, Colors Colors, string Tooltip) BuildLightfinderIndicator()
|
||||
{
|
||||
var config = _configService.Current;
|
||||
const string icon = "\uE048";
|
||||
if (!_broadcastService.IsLightFinderAvailable)
|
||||
{
|
||||
return ($"{icon} --", SwapColorChannels(config.DtrColorsLightfinderUnavailable), "Lightfinder - Unavailable on this server.");
|
||||
}
|
||||
|
||||
if (_broadcastService.IsBroadcasting)
|
||||
{
|
||||
switch (config.LightfinderDtrDisplayMode)
|
||||
{
|
||||
case LightfinderDtrDisplayMode.PendingPairRequests:
|
||||
{
|
||||
return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
|
||||
}
|
||||
default:
|
||||
{
|
||||
return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tooltip = new StringBuilder("Lightfinder - Disabled");
|
||||
var colors = SwapColorChannels(config.DtrColorsLightfinderDisabled);
|
||||
if (_broadcastService.RemainingCooldown is { } cooldown && cooldown > TimeSpan.Zero)
|
||||
{
|
||||
tooltip.AppendLine();
|
||||
tooltip.Append("Cooldown: ").Append(Math.Ceiling(cooldown.TotalSeconds)).Append("s");
|
||||
colors = SwapColorChannels(config.DtrColorsLightfinderCooldown);
|
||||
}
|
||||
|
||||
return ($"{icon} OFF", colors, tooltip.ToString());
|
||||
}
|
||||
|
||||
private (string, Colors, string) FormatTooltip(string title, IEnumerable<string> names, string icon, Colors color)
|
||||
{
|
||||
var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList();
|
||||
var tooltip = new StringBuilder()
|
||||
.Append($"Lightfinder - Enabled{Environment.NewLine}")
|
||||
.Append($"{title}: {list.Count}{Environment.NewLine}")
|
||||
.AppendJoin(Environment.NewLine, list)
|
||||
.ToString();
|
||||
|
||||
return ($"{icon} {list.Count}", color, tooltip);
|
||||
}
|
||||
|
||||
private static string BuildLightfinderTooltip(string baseTooltip)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(baseTooltip))
|
||||
builder.Append(baseTooltip.TrimEnd());
|
||||
else
|
||||
builder.Append("Lightfinder status unavailable.");
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void AppendColoredSegment(SeStringBuilder builder, string? text, Colors colors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
if (colors.Foreground != default)
|
||||
builder.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground));
|
||||
if (colors.Glow != default)
|
||||
builder.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow));
|
||||
|
||||
builder.AddText(text);
|
||||
|
||||
if (colors.Glow != default)
|
||||
builder.Add(BuildColorEndPayload(_colorTypeGlow));
|
||||
if (colors.Foreground != default)
|
||||
builder.Add(BuildColorEndPayload(_colorTypeForeground));
|
||||
}
|
||||
|
||||
#region Colored SeString
|
||||
private const byte _colorTypeForeground = 0x13;
|
||||
private const byte _colorTypeGlow = 0x14;
|
||||
|
||||
private static Colors SwapColorChannels(Colors colors)
|
||||
=> new(SwapColorComponent(colors.Foreground), SwapColorComponent(colors.Glow));
|
||||
|
||||
private static uint SwapColorComponent(uint color)
|
||||
{
|
||||
if (color == 0)
|
||||
return 0;
|
||||
|
||||
return ((color & 0xFFu) << 16) | (color & 0xFF00u) | ((color >> 16) & 0xFFu);
|
||||
}
|
||||
|
||||
private static SeString BuildColoredSeString(string text, Colors colors)
|
||||
{
|
||||
var ssb = new SeStringBuilder();
|
||||
if (colors.Foreground != default)
|
||||
ssb.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground));
|
||||
if (colors.Glow != default)
|
||||
ssb.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow));
|
||||
ssb.AddText(text);
|
||||
if (colors.Glow != default)
|
||||
ssb.Add(BuildColorEndPayload(_colorTypeGlow));
|
||||
if (colors.Foreground != default)
|
||||
ssb.Add(BuildColorEndPayload(_colorTypeForeground));
|
||||
AppendColoredSegment(ssb, text, colors);
|
||||
return ssb.Build();
|
||||
}
|
||||
|
||||
private static RawPayload BuildColorStartPayload(byte colorType, uint color)
|
||||
=> new(unchecked([0x02, colorType, 0x05, 0xF6, byte.Max((byte)color, 0x01), byte.Max((byte)(color >> 8), 0x01), byte.Max((byte)(color >> 16), 0x01), 0x03]));
|
||||
=> new(unchecked([
|
||||
0x02,
|
||||
colorType,
|
||||
0x05,
|
||||
0xF6,
|
||||
byte.Max((byte)color, (byte)0x01),
|
||||
byte.Max((byte)(color >> 8), (byte)0x01),
|
||||
byte.Max((byte)(color >> 16), (byte)0x01),
|
||||
0x03
|
||||
]));
|
||||
|
||||
private static RawPayload BuildColorEndPayload(byte colorType)
|
||||
=> new([0x02, colorType, 0x02, 0xEC, 0x03]);
|
||||
|
||||
@@ -4,14 +4,18 @@ using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Style;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
@@ -30,6 +34,16 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
private bool _showFileDialogError = false;
|
||||
private bool _wasOpen;
|
||||
|
||||
private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f);
|
||||
private bool vanityInitialized; // useless for now
|
||||
private bool textEnabled;
|
||||
private bool glowEnabled;
|
||||
private Vector4 textColor;
|
||||
private Vector4 glowColor;
|
||||
|
||||
private record VanityState(bool TextEnabled, bool GlowEnabled, Vector4 TextColor, Vector4 GlowColor);
|
||||
private VanityState _savedVanity;
|
||||
|
||||
public EditProfileUi(ILogger<EditProfileUi> logger, LightlessMediator mediator,
|
||||
ApiController apiController, UiSharedService uiSharedService, FileDialogManager fileDialogManager,
|
||||
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService)
|
||||
@@ -38,8 +52,8 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
IsOpen = false;
|
||||
this.SizeConstraints = new()
|
||||
{
|
||||
MinimumSize = new(768, 512),
|
||||
MaximumSize = new(768, 2000)
|
||||
MinimumSize = new(850, 640),
|
||||
MaximumSize = new(850, 700)
|
||||
};
|
||||
_apiController = apiController;
|
||||
_uiSharedService = uiSharedService;
|
||||
@@ -49,7 +63,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; });
|
||||
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen);
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) =>
|
||||
Mediator.Subscribe<ClearProfileUserDataMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -57,172 +71,322 @@ public class EditProfileUi : WindowMediatorSubscriberBase
|
||||
_pfpTextureWrap = null;
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<ConnectedMessage>(this, msg =>
|
||||
{
|
||||
LoadVanity();
|
||||
});
|
||||
}
|
||||
|
||||
private void LoadVanity()
|
||||
{
|
||||
textEnabled = !string.IsNullOrEmpty(_apiController.TextColorHex);
|
||||
glowEnabled = !string.IsNullOrEmpty(_apiController.TextGlowColorHex);
|
||||
|
||||
textColor = textEnabled ? UIColors.HexToRgba(_apiController.TextColorHex!) : Vector4.One;
|
||||
glowColor = glowEnabled ? UIColors.HexToRgba(_apiController.TextGlowColorHex!) : Vector4.Zero;
|
||||
|
||||
_savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor);
|
||||
vanityInitialized = true;
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
_uiSharedService.BigText("Current Profile (as saved on server)");
|
||||
|
||||
var profile = _lightlessProfileManager.GetLightlessProfile(new UserData(_apiController.UID));
|
||||
_uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow"));
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
if (profile.IsFlagged)
|
||||
{
|
||||
UiSharedService.ColorTextWrapped(profile.Description, ImGuiColors.DalamudRed);
|
||||
return;
|
||||
}
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, 1));
|
||||
|
||||
if (!_profileImage.SequenceEqual(profile.ImageData.Value))
|
||||
{
|
||||
_profileImage = profile.ImageData.Value;
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
|
||||
}
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile picture and description.");
|
||||
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report your profile for breaking the rules.");
|
||||
_uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)");
|
||||
_uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive");
|
||||
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling your profile forever or terminating your Lightless account indefinitely.");
|
||||
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of your profile validity from reports through staff is not up to debate and the decisions to disable your profile/account permanent.");
|
||||
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If your profile picture or profile description could be considered NSFW, enable the toggle in profile settings.");
|
||||
|
||||
if (!string.Equals(_profileDescription, profile.Description, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_profileDescription = profile.Description;
|
||||
_descriptionText = _profileDescription;
|
||||
}
|
||||
ImGui.PopStyleVar();
|
||||
|
||||
if (_pfpTextureWrap != null)
|
||||
{
|
||||
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
|
||||
}
|
||||
ImGui.Dummy(new Vector2(3));
|
||||
|
||||
var spacing = ImGui.GetStyle().ItemSpacing.X;
|
||||
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSize = ImGui.CalcTextSize(profile.Description, wrapWidth: 256f);
|
||||
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
|
||||
if (descriptionTextSize.Y > childFrame.Y)
|
||||
var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID));
|
||||
_logger.LogInformation("Profile fetched for drawing: {profile}", profile);
|
||||
|
||||
if (ImGui.BeginTabBar("##EditProfileTabs"))
|
||||
{
|
||||
if (ImGui.BeginTabItem("Current Profile"))
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = false;
|
||||
}
|
||||
childFrame = childFrame with
|
||||
{
|
||||
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(101, childFrame))
|
||||
{
|
||||
UiSharedService.TextWrapped(profile.Description);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
}
|
||||
_uiSharedService.MediumText("Current Profile (as saved on server)", UIColors.Get("LightlessPurple"));
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
var nsfw = profile.IsNSFW;
|
||||
ImGui.BeginDisabled();
|
||||
ImGui.Checkbox("Is NSFW", ref nsfw);
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.Separator();
|
||||
_uiSharedService.BigText("Notes and Rules for Profiles");
|
||||
|
||||
ImGui.TextWrapped($"- All users that are paired and unpaused with you will be able to see your profile picture and description.{Environment.NewLine}" +
|
||||
$"- Other users have the possibility to report your profile for breaking the rules.{Environment.NewLine}" +
|
||||
$"- !!! AVOID: anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.){Environment.NewLine}" +
|
||||
$"- !!! AVOID: slurs of any kind in the description that can be considered highly offensive{Environment.NewLine}" +
|
||||
$"- In case of valid reports from other users this can lead to disabling your profile forever or terminating your Lightless account indefinitely.{Environment.NewLine}" +
|
||||
$"- Judgement of your profile validity from reports through staff is not up to debate and the decisions to disable your profile/account permanent.{Environment.NewLine}" +
|
||||
$"- If your profile picture or profile description could be considered NSFW, enable the toggle below.");
|
||||
ImGui.Separator();
|
||||
_uiSharedService.BigText("Profile Settings");
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
|
||||
{
|
||||
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
|
||||
{
|
||||
if (!success) return;
|
||||
_ = Task.Run(async () =>
|
||||
if (profile.IsFlagged)
|
||||
{
|
||||
var fileContent = File.ReadAllBytes(file);
|
||||
using MemoryStream ms = new(fileContent);
|
||||
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
|
||||
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
|
||||
UiSharedService.ColorTextWrapped(profile.Description, ImGuiColors.DalamudRed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_profileImage.SequenceEqual(profile.ImageData.Value))
|
||||
{
|
||||
_profileImage = profile.ImageData.Value;
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
|
||||
}
|
||||
|
||||
if (!string.Equals(_profileDescription, profile.Description, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_profileDescription = profile.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(profile.Description, wrapWidth: 256f);
|
||||
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
|
||||
if (descriptionTextSize.Y > childFrame.Y)
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
_adjustedForScollBarsOnlineProfile = true;
|
||||
}
|
||||
using var image = Image.Load<Rgba32>(fileContent);
|
||||
|
||||
if (image.Width > 256 || image.Height > 256 || (fileContent.Length > 250 * 1024))
|
||||
else
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
_adjustedForScollBarsOnlineProfile = false;
|
||||
}
|
||||
childFrame = childFrame with
|
||||
{
|
||||
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(101, childFrame))
|
||||
{
|
||||
UiSharedService.TextWrapped(profile.Description);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
}
|
||||
|
||||
_showFileDialogError = false;
|
||||
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null))
|
||||
.ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
UiSharedService.AttachToolTip("Select and upload a new profile picture");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null));
|
||||
}
|
||||
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);
|
||||
}
|
||||
var isNsfw = profile.IsNSFW;
|
||||
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null));
|
||||
}
|
||||
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
|
||||
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));
|
||||
var nsfw = profile.IsNSFW;
|
||||
ImGui.BeginDisabled();
|
||||
ImGui.Checkbox("Is NSFW", ref nsfw);
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
|
||||
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
|
||||
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
|
||||
{
|
||||
_adjustedForScollBarsLocalProfile = true;
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
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.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText));
|
||||
if (ImGui.BeginTabItem("Profile Settings"))
|
||||
{
|
||||
_uiSharedService.MediumText("Profile Settings", UIColors.Get("LightlessPurple"));
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
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 = File.ReadAllBytes(file);
|
||||
using MemoryStream ms = new(fileContent);
|
||||
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 > 256 || image.Height > 256 || (fileContent.Length > 250 * 1024))
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_showFileDialogError = false;
|
||||
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), BannerPictureBase64: null, Description: null, Tags: null))
|
||||
.ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
UiSharedService.AttachToolTip("Select and upload a new profile picture");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null, BannerPictureBase64: null, Tags: 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);
|
||||
}
|
||||
var isNsfw = profile.IsNSFW;
|
||||
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null, BannerPictureBase64: null, Tags: null));
|
||||
}
|
||||
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
|
||||
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);
|
||||
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
|
||||
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.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, _descriptionText, Tags: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Sets your profile description text");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, "", Tags: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clears your profile description text");
|
||||
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
|
||||
if (ImGui.BeginTabItem("Vanity Settings"))
|
||||
{
|
||||
_uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple"));
|
||||
ImGui.Dummy(new Vector2(4));
|
||||
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings. If you have the vanity role, you must interact with the Discord bot first.");
|
||||
|
||||
var hasVanity = _apiController.HasVanity;
|
||||
|
||||
if (!hasVanity)
|
||||
{
|
||||
UiSharedService.ColorTextWrapped("You do not currently have vanity access. Become a supporter to unlock these features.", UIColors.Get("DimRed"));
|
||||
ImGui.Dummy(new Vector2(8));
|
||||
ImGui.BeginDisabled();
|
||||
}
|
||||
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||
_uiSharedService.MediumText("Colored UID", UIColors.Get("LightlessPurple"));
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
var font = UiBuilder.MonoFont;
|
||||
var playerUID = _apiController.UID;
|
||||
var playerDisplay = _apiController.DisplayName;
|
||||
|
||||
var previewTextColor = textEnabled ? textColor : Vector4.One;
|
||||
var previewGlowColor = glowEnabled ? glowColor : Vector4.Zero;
|
||||
|
||||
var seString = SeStringUtils.BuildFormattedPlayerName(playerDisplay, previewTextColor, previewGlowColor);
|
||||
|
||||
using (ImRaii.PushFont(font))
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var textSize = ImGui.CalcTextSize(seString.TextValue);
|
||||
|
||||
float minWidth = 150f * ImGuiHelpers.GlobalScale;
|
||||
float bgWidth = Math.Max(textSize.X + 20f, minWidth);
|
||||
|
||||
float paddingY = 5f * ImGuiHelpers.GlobalScale;
|
||||
|
||||
var cursor = ImGui.GetCursorScreenPos();
|
||||
|
||||
var rectMin = cursor;
|
||||
var rectMax = rectMin + new Vector2(bgWidth, textSize.Y + (paddingY * 2f));
|
||||
|
||||
float boost = Luminance.ComputeHighlight(previewTextColor, previewGlowColor);
|
||||
|
||||
var baseBg = new Vector4(0.15f + boost, 0.15f + boost, 0.15f + boost, 1f);
|
||||
var bgColor = Luminance.BackgroundContrast(previewTextColor, previewGlowColor, baseBg, ref _currentBg);
|
||||
|
||||
var borderColor = UIColors.Get("LightlessPurple");
|
||||
|
||||
drawList.AddRectFilled(rectMin, rectMax, ImGui.GetColorU32(bgColor), 6.0f);
|
||||
drawList.AddRect(rectMin, rectMax, ImGui.GetColorU32(borderColor), 6.0f, ImDrawFlags.None, 1.5f);
|
||||
|
||||
var textPos = new Vector2(
|
||||
rectMin.X + (bgWidth - textSize.X) * 0.5f,
|
||||
rectMin.Y + paddingY
|
||||
);
|
||||
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, textPos, font);
|
||||
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
}
|
||||
|
||||
const float colorPickAlign = 90f;
|
||||
|
||||
_uiSharedService.DrawNoteLine("- ", UIColors.Get("LightlessPurple"), "Text Color");
|
||||
ImGui.SameLine(colorPickAlign);
|
||||
ImGui.Checkbox("##toggleTextColor", ref textEnabled);
|
||||
ImGui.SameLine();
|
||||
ImGui.BeginDisabled(!textEnabled);
|
||||
ImGui.ColorEdit4($"##color_text", ref textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf);
|
||||
ImGui.EndDisabled();
|
||||
|
||||
_uiSharedService.DrawNoteLine("- ", UIColors.Get("LightlessPurple"), "Glow Color");
|
||||
ImGui.SameLine(colorPickAlign);
|
||||
ImGui.Checkbox("##toggleGlowColor", ref glowEnabled);
|
||||
ImGui.SameLine();
|
||||
ImGui.BeginDisabled(!glowEnabled);
|
||||
ImGui.ColorEdit4($"##color_glow", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf);
|
||||
ImGui.EndDisabled();
|
||||
|
||||
bool changed = !Equals(_savedVanity, new VanityState(textEnabled, glowEnabled, textColor, glowColor));
|
||||
|
||||
if (!changed)
|
||||
ImGui.BeginDisabled();
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Changes"))
|
||||
{
|
||||
string? newText = textEnabled ? UIColors.RgbaToHex(textColor) : string.Empty;
|
||||
string? newGlow = glowEnabled ? UIColors.RgbaToHex(glowColor) : string.Empty;
|
||||
|
||||
_ = _apiController.UserUpdateVanityColors(new UserVanityColorsDto(newText, newGlow));
|
||||
|
||||
_savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor);
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
|
||||
if (!hasVanity)
|
||||
ImGui.EndDisabled();
|
||||
|
||||
ImGui.EndTabItem();
|
||||
}
|
||||
|
||||
ImGui.EndTabBar();
|
||||
}
|
||||
UiSharedService.AttachToolTip("Sets your profile description text");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
|
||||
{
|
||||
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, ""));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clears your profile description text");
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Style;
|
||||
using LightlessSync.Utils;
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI.Handlers;
|
||||
|
||||
@@ -22,6 +27,9 @@ public class IdDisplayHandler
|
||||
private bool _popupShown = false;
|
||||
private DateTime? _popupTime;
|
||||
|
||||
private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f);
|
||||
private float _highlightBoost;
|
||||
|
||||
public IdDisplayHandler(LightlessMediator mediator, ServerConfigurationManager serverManager, LightlessConfigService lightlessConfigService)
|
||||
{
|
||||
_mediator = mediator;
|
||||
@@ -89,11 +97,108 @@ public class IdDisplayHandler
|
||||
{
|
||||
ImGui.SameLine(textPosX);
|
||||
(bool textIsUid, string playerText) = GetPlayerText(pair);
|
||||
|
||||
if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal))
|
||||
{
|
||||
ImGui.AlignTextToFramePadding();
|
||||
|
||||
using (ImRaii.PushFont(UiBuilder.MonoFont, textIsUid)) ImGui.TextUnformatted(playerText);
|
||||
var font = textIsUid ? UiBuilder.MonoFont : ImGui.GetFont();
|
||||
|
||||
Vector4? textColor = null;
|
||||
Vector4? glowColor = null;
|
||||
|
||||
if (pair.UserData.HasVanity)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.UserData.TextColorHex))
|
||||
{
|
||||
textColor = UIColors.HexToRgba(pair.UserData.TextColorHex);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pair.UserData.TextGlowColorHex))
|
||||
{
|
||||
glowColor = UIColors.HexToRgba(pair.UserData.TextGlowColorHex);
|
||||
}
|
||||
}
|
||||
|
||||
var useVanityColors = _lightlessConfigService.Current.useColoredUIDs && (textColor != null || glowColor != null);
|
||||
var seString = useVanityColors
|
||||
? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor)
|
||||
: SeStringUtils.BuildPlain(playerText);
|
||||
|
||||
var rowStart = ImGui.GetCursorScreenPos();
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
bool useHighlight = false;
|
||||
float highlightPadX = 0f;
|
||||
float highlightPadY = 0f;
|
||||
|
||||
if (useVanityColors)
|
||||
{
|
||||
float boost = Luminance.ComputeHighlight(textColor, glowColor);
|
||||
|
||||
if (boost > 0f)
|
||||
{
|
||||
var style = ImGui.GetStyle();
|
||||
useHighlight = true;
|
||||
highlightPadX = MathF.Max(style.FramePadding.X * 0.6f, 2f * ImGuiHelpers.GlobalScale);
|
||||
highlightPadY = MathF.Max(style.FramePadding.Y * 0.55f, 1.25f * ImGuiHelpers.GlobalScale);
|
||||
drawList.ChannelsSplit(2);
|
||||
drawList.ChannelsSetCurrent(1);
|
||||
|
||||
_highlightBoost = boost;
|
||||
}
|
||||
else
|
||||
{
|
||||
_highlightBoost = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
Vector2 itemMin;
|
||||
Vector2 itemMax;
|
||||
Vector2 textSize;
|
||||
using (ImRaii.PushFont(font, textIsUid))
|
||||
{
|
||||
SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID);
|
||||
itemMin = ImGui.GetItemRectMin();
|
||||
itemMax = ImGui.GetItemRectMax();
|
||||
//textSize = itemMax - itemMin;
|
||||
}
|
||||
|
||||
if (useHighlight)
|
||||
{
|
||||
var style = ImGui.GetStyle();
|
||||
var frameHeight = ImGui.GetFrameHeight();
|
||||
var rowTop = rowStart.Y - style.FramePadding.Y;
|
||||
var rowBottom = rowTop + frameHeight;
|
||||
|
||||
var highlightMin = new Vector2(itemMin.X - highlightPadX, rowTop - highlightPadY);
|
||||
var highlightMax = new Vector2(itemMax.X + highlightPadX, rowBottom + highlightPadY);
|
||||
|
||||
var windowPos = ImGui.GetWindowPos();
|
||||
var contentMin = windowPos + ImGui.GetWindowContentRegionMin();
|
||||
var contentMax = windowPos + ImGui.GetWindowContentRegionMax();
|
||||
highlightMin.X = MathF.Max(highlightMin.X, contentMin.X);
|
||||
highlightMax.X = MathF.Min(highlightMax.X, contentMax.X);
|
||||
highlightMin.Y = MathF.Max(highlightMin.Y, contentMin.Y);
|
||||
highlightMax.Y = MathF.Min(highlightMax.Y, contentMax.Y);
|
||||
|
||||
var highlightColor = new Vector4(
|
||||
0.25f + _highlightBoost,
|
||||
0.25f + _highlightBoost,
|
||||
0.25f + _highlightBoost,
|
||||
1f
|
||||
);
|
||||
|
||||
highlightColor = Luminance.BackgroundContrast(textColor, glowColor, highlightColor, ref _currentBg);
|
||||
|
||||
float rounding = style.FrameRounding > 0f ? style.FrameRounding : 5f * ImGuiHelpers.GlobalScale;
|
||||
drawList.ChannelsSetCurrent(0);
|
||||
drawList.AddRectFilled(highlightMin, highlightMax, ImGui.GetColorU32(highlightColor), rounding);
|
||||
|
||||
var borderColor = style.Colors[(int)ImGuiCol.Border];
|
||||
borderColor.W *= 0.25f;
|
||||
drawList.AddRect(highlightMin, highlightMax, ImGui.GetColorU32(borderColor), rounding);
|
||||
drawList.ChannelsMerge();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
@@ -173,10 +278,12 @@ public class IdDisplayHandler
|
||||
{
|
||||
_editEntry = string.Empty;
|
||||
}
|
||||
|
||||
UiSharedService.AttachToolTip("Hit ENTER to save\nRight click to cancel");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public (bool isGid, string text) GetGroupText(GroupFullInfoDto group)
|
||||
{
|
||||
var textIsGid = true;
|
||||
|
||||
@@ -167,7 +167,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
else
|
||||
{
|
||||
UiSharedService.TextWrapped("To not unnecessary download files already present on your computer, Lightless Sync will have to scan your Penumbra mod directory. " +
|
||||
UiSharedService.TextWrapped("To not unnecessarily download files already present on your computer, Lightless Sync will have to scan your Penumbra mod directory. " +
|
||||
"Additionally, a local storage folder must be set where Lightless Sync will download other character files to. " +
|
||||
"Once the storage folder is set and the scan complete, this page will automatically forward to registration at a service.");
|
||||
UiSharedService.TextWrapped("Note: The initial scan, depending on the amount of mods you have, might take a while. Please wait until it is completed.");
|
||||
|
||||
@@ -63,7 +63,7 @@ internal class JoinSyncshellUI : WindowMediatorSubscriberBase
|
||||
"Joining a Syncshell will pair you implicitly with all existing users in the Syncshell." + Environment.NewLine +
|
||||
"All permissions to all users in the Syncshell will be set to the preferred Syncshell permissions on joining, excluding prior set preferred permissions.");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted("Note: Syncshell ID and Password are case sensitive. MSS- is part of Syncshell IDs, unless using Vanity IDs.");
|
||||
ImGui.TextUnformatted("Note: Syncshell ID and Password are case sensitive. LLS- is part of Syncshell IDs, unless using Vanity IDs.");
|
||||
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted("Syncshell ID");
|
||||
|
||||
754
LightlessSync/UI/LightlessNotificationUI.cs
Normal file
754
LightlessSync/UI/LightlessNotificationUI.cs
Normal file
@@ -0,0 +1,754 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System.Numerics;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
private const float _notificationMinHeight = 60f;
|
||||
private const float _notificationMaxHeight = 250f;
|
||||
private const float _windowPaddingOffset = 6f;
|
||||
private const float _slideAnimationDistance = 100f;
|
||||
private const float _outAnimationSpeedMultiplier = 0.7f;
|
||||
private const float _contentPaddingX = 10f;
|
||||
private const float _contentPaddingY = 6f;
|
||||
private const float _titleMessageSpacing = 4f;
|
||||
private const float _actionButtonSpacing = 8f;
|
||||
|
||||
private readonly List<LightlessNotification> _notifications = new();
|
||||
private readonly object _notificationLock = new();
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly Dictionary<string, float> _notificationYOffsets = new();
|
||||
private readonly Dictionary<string, float> _notificationTargetYOffsets = new();
|
||||
|
||||
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
||||
{
|
||||
_configService = configService;
|
||||
Flags = ImGuiWindowFlags.NoDecoration |
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoResize |
|
||||
ImGuiWindowFlags.NoSavedSettings |
|
||||
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||
ImGuiWindowFlags.NoNav |
|
||||
ImGuiWindowFlags.NoBackground |
|
||||
ImGuiWindowFlags.NoCollapse |
|
||||
ImGuiWindowFlags.NoInputs |
|
||||
ImGuiWindowFlags.NoTitleBar |
|
||||
ImGuiWindowFlags.NoScrollbar |
|
||||
ImGuiWindowFlags.AlwaysAutoResize;
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
SizeCondition = ImGuiCond.FirstUseEver;
|
||||
IsOpen = false;
|
||||
RespectCloseHotkey = false;
|
||||
DisableWindowSounds = true;
|
||||
|
||||
Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage);
|
||||
Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage);
|
||||
Mediator.Subscribe<ClearAllNotificationsMessage>(this, HandleClearAllNotifications);
|
||||
}
|
||||
private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification);
|
||||
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId);
|
||||
private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications();
|
||||
|
||||
public void AddNotification(LightlessNotification notification)
|
||||
{
|
||||
lock (_notificationLock)
|
||||
{
|
||||
var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id);
|
||||
if (existingNotification != null)
|
||||
{
|
||||
UpdateExistingNotification(existingNotification, notification);
|
||||
}
|
||||
else
|
||||
{
|
||||
_notifications.Add(notification);
|
||||
_logger.LogDebug("Added new notification: {Title}", notification.Title);
|
||||
}
|
||||
|
||||
if (!IsOpen) IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateExistingNotification(LightlessNotification existing, LightlessNotification updated)
|
||||
{
|
||||
existing.Message = updated.Message;
|
||||
existing.Progress = updated.Progress;
|
||||
existing.ShowProgress = updated.ShowProgress;
|
||||
existing.Title = updated.Title;
|
||||
|
||||
// Reset the duration timer on every update for download notifications
|
||||
if (updated.Type == NotificationType.Download)
|
||||
{
|
||||
existing.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Updated existing notification: {Title}", updated.Title);
|
||||
}
|
||||
|
||||
public void RemoveNotification(string id)
|
||||
{
|
||||
lock (_notificationLock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == id);
|
||||
if (notification != null)
|
||||
{
|
||||
StartOutAnimation(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearAllNotifications()
|
||||
{
|
||||
lock (_notificationLock)
|
||||
{
|
||||
foreach (var notification in _notifications)
|
||||
{
|
||||
StartOutAnimation(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartOutAnimation(LightlessNotification notification)
|
||||
{
|
||||
notification.IsAnimatingOut = true;
|
||||
notification.IsAnimatingIn = false;
|
||||
}
|
||||
|
||||
private bool ShouldRemoveNotification(LightlessNotification notification) =>
|
||||
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||
|
||||
lock (_notificationLock)
|
||||
{
|
||||
UpdateNotifications();
|
||||
|
||||
if (_notifications.Count == 0)
|
||||
{
|
||||
ImGui.PopStyleVar();
|
||||
IsOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
|
||||
// Window auto-resizes based on content (AlwaysAutoResize flag)
|
||||
Position = CalculateWindowPosition(viewport);
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
DrawAllNotifications();
|
||||
}
|
||||
|
||||
ImGui.PopStyleVar();
|
||||
}
|
||||
|
||||
private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport)
|
||||
{
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
var offsetX = _configService.Current.NotificationOffsetX;
|
||||
var width = _configService.Current.NotificationWidth;
|
||||
|
||||
float posX = corner == NotificationCorner.Left
|
||||
? viewport.WorkPos.X + offsetX - _windowPaddingOffset
|
||||
: viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset;
|
||||
|
||||
return new Vector2(posX, viewport.WorkPos.Y);
|
||||
}
|
||||
|
||||
private void DrawAllNotifications()
|
||||
{
|
||||
var offsetY = _configService.Current.NotificationOffsetY;
|
||||
var startY = ImGui.GetCursorPosY() + offsetY;
|
||||
|
||||
for (int i = 0; i < _notifications.Count; i++)
|
||||
{
|
||||
var notification = _notifications[i];
|
||||
|
||||
if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset))
|
||||
{
|
||||
ImGui.SetCursorPosY(startY + yOffset);
|
||||
}
|
||||
|
||||
DrawNotification(notification, i);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNotifications()
|
||||
{
|
||||
var deltaTime = ImGui.GetIO().DeltaTime;
|
||||
EnforceMaxNotificationLimit();
|
||||
UpdateAnimationsAndRemoveExpired(deltaTime);
|
||||
}
|
||||
|
||||
private void EnforceMaxNotificationLimit()
|
||||
{
|
||||
var maxNotifications = _configService.Current.MaxSimultaneousNotifications;
|
||||
while (_notifications.Count(n => !n.IsAnimatingOut) > maxNotifications)
|
||||
{
|
||||
var oldestNotification = _notifications
|
||||
.Where(n => !n.IsAnimatingOut)
|
||||
.OrderBy(n => n.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldestNotification != null)
|
||||
{
|
||||
StartOutAnimation(oldestNotification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAnimationsAndRemoveExpired(float deltaTime)
|
||||
{
|
||||
UpdateTargetYPositions();
|
||||
|
||||
for (int i = _notifications.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var notification = _notifications[i];
|
||||
UpdateNotificationAnimation(notification, deltaTime);
|
||||
UpdateNotificationYOffset(notification, deltaTime);
|
||||
|
||||
if (ShouldRemoveNotification(notification))
|
||||
{
|
||||
_notifications.RemoveAt(i);
|
||||
_notificationYOffsets.Remove(notification.Id);
|
||||
_notificationTargetYOffsets.Remove(notification.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTargetYPositions()
|
||||
{
|
||||
float currentY = 0f;
|
||||
|
||||
for (int i = 0; i < _notifications.Count; i++)
|
||||
{
|
||||
var notification = _notifications[i];
|
||||
|
||||
if (!_notificationTargetYOffsets.ContainsKey(notification.Id))
|
||||
{
|
||||
_notificationTargetYOffsets[notification.Id] = currentY;
|
||||
_notificationYOffsets[notification.Id] = currentY;
|
||||
}
|
||||
else
|
||||
{
|
||||
_notificationTargetYOffsets[notification.Id] = currentY;
|
||||
}
|
||||
|
||||
currentY += CalculateNotificationHeight(notification) + _configService.Current.NotificationSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNotificationYOffset(LightlessNotification notification, float deltaTime)
|
||||
{
|
||||
if (!_notificationYOffsets.ContainsKey(notification.Id) || !_notificationTargetYOffsets.ContainsKey(notification.Id))
|
||||
return;
|
||||
|
||||
var current = _notificationYOffsets[notification.Id];
|
||||
var target = _notificationTargetYOffsets[notification.Id];
|
||||
var diff = target - current;
|
||||
|
||||
if (Math.Abs(diff) < 0.5f)
|
||||
{
|
||||
_notificationYOffsets[notification.Id] = target;
|
||||
}
|
||||
else
|
||||
{
|
||||
var speed = _configService.Current.NotificationSlideSpeed;
|
||||
_notificationYOffsets[notification.Id] = current + (diff * deltaTime * speed);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime)
|
||||
{
|
||||
if (notification.IsAnimatingIn && notification.AnimationProgress < 1f)
|
||||
{
|
||||
notification.AnimationProgress = Math.Min(1f,
|
||||
notification.AnimationProgress + deltaTime * _configService.Current.NotificationAnimationSpeed);
|
||||
}
|
||||
else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f)
|
||||
{
|
||||
notification.AnimationProgress = Math.Max(0f,
|
||||
notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * _outAnimationSpeedMultiplier);
|
||||
}
|
||||
else if (!notification.IsAnimatingOut && !notification.IsDismissed)
|
||||
{
|
||||
notification.IsAnimatingIn = false;
|
||||
|
||||
if (notification.IsExpired)
|
||||
{
|
||||
StartOutAnimation(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 CalculateSlideOffset(float alpha)
|
||||
{
|
||||
var distance = (1f - alpha) * _slideAnimationDistance;
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0);
|
||||
}
|
||||
|
||||
private void DrawNotification(LightlessNotification notification, int index)
|
||||
{
|
||||
var alpha = notification.AnimationProgress;
|
||||
if (alpha <= 0f) return;
|
||||
|
||||
var slideOffset = CalculateSlideOffset(alpha);
|
||||
var originalCursorPos = ImGui.GetCursorPos();
|
||||
ImGui.SetCursorPos(originalCursorPos + slideOffset);
|
||||
|
||||
var notificationHeight = CalculateNotificationHeight(notification);
|
||||
var notificationWidth = _configService.Current.NotificationWidth;
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||
|
||||
using var child = ImRaii.Child($"notification_{notification.Id}",
|
||||
new Vector2(notificationWidth, notificationHeight),
|
||||
false, ImGuiWindowFlags.NoScrollbar);
|
||||
|
||||
if (child.Success)
|
||||
{
|
||||
DrawNotificationContent(notification, alpha);
|
||||
}
|
||||
|
||||
ImGui.PopStyleVar();
|
||||
}
|
||||
|
||||
private void DrawNotificationContent(LightlessNotification notification, float alpha)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var windowPos = ImGui.GetWindowPos();
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
|
||||
var bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered());
|
||||
var accentColor = GetNotificationAccentColor(notification.Type);
|
||||
accentColor.W *= alpha;
|
||||
|
||||
DrawShadow(drawList, windowPos, windowSize, alpha);
|
||||
HandleClickToDismiss(notification);
|
||||
DrawBackground(drawList, windowPos, windowSize, bgColor);
|
||||
DrawAccentBar(drawList, windowPos, windowSize, accentColor);
|
||||
DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList);
|
||||
|
||||
// Draw download progress bar above duration bar for download notifications
|
||||
if (notification.Type == NotificationType.Download && notification.ShowProgress)
|
||||
{
|
||||
DrawDownloadProgressBar(notification, alpha, windowPos, windowSize, drawList);
|
||||
}
|
||||
|
||||
DrawNotificationText(notification, alpha);
|
||||
}
|
||||
|
||||
private Vector4 CalculateBackgroundColor(float alpha, bool isHovered)
|
||||
{
|
||||
var baseOpacity = _configService.Current.NotificationOpacity;
|
||||
var finalOpacity = baseOpacity * alpha;
|
||||
var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity);
|
||||
|
||||
if (isHovered)
|
||||
{
|
||||
bgColor *= 1.1f;
|
||||
bgColor.W = Math.Min(bgColor.W, 0.98f);
|
||||
}
|
||||
|
||||
return bgColor;
|
||||
}
|
||||
|
||||
private void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha)
|
||||
{
|
||||
var shadowOffset = new Vector2(1f, 1f);
|
||||
var shadowColor = new Vector4(0f, 0f, 0f, 0.4f * alpha);
|
||||
drawList.AddRectFilled(
|
||||
windowPos + shadowOffset,
|
||||
windowPos + windowSize + shadowOffset,
|
||||
ImGui.ColorConvertFloat4ToU32(shadowColor),
|
||||
3f
|
||||
);
|
||||
}
|
||||
|
||||
private void HandleClickToDismiss(LightlessNotification notification)
|
||||
{
|
||||
if (ImGui.IsWindowHovered() &&
|
||||
_configService.Current.DismissNotificationOnClick &&
|
||||
!notification.Actions.Any() &&
|
||||
ImGui.IsMouseClicked(ImGuiMouseButton.Left))
|
||||
{
|
||||
notification.IsDismissed = true;
|
||||
StartOutAnimation(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor)
|
||||
{
|
||||
drawList.AddRectFilled(
|
||||
windowPos,
|
||||
windowPos + windowSize,
|
||||
ImGui.ColorConvertFloat4ToU32(bgColor),
|
||||
3f
|
||||
);
|
||||
}
|
||||
|
||||
private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor)
|
||||
{
|
||||
var accentWidth = _configService.Current.NotificationAccentBarWidth;
|
||||
if (accentWidth <= 0f) return;
|
||||
|
||||
var corner = _configService.Current.NotificationCorner;
|
||||
Vector2 accentStart, accentEnd;
|
||||
|
||||
if (corner == NotificationCorner.Left)
|
||||
{
|
||||
accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0);
|
||||
accentEnd = windowPos + windowSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
accentStart = windowPos;
|
||||
accentEnd = windowPos + new Vector2(accentWidth, windowSize.Y);
|
||||
}
|
||||
|
||||
drawList.AddRectFilled(
|
||||
accentStart,
|
||||
accentEnd,
|
||||
ImGui.ColorConvertFloat4ToU32(accentColor),
|
||||
3f
|
||||
);
|
||||
}
|
||||
|
||||
private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
|
||||
{
|
||||
var progress = CalculateDurationProgress(notification);
|
||||
var progressBarColor = UIColors.Get("LightlessBlue");
|
||||
var progressHeight = 2f;
|
||||
var progressY = windowPos.Y + windowSize.Y - progressHeight;
|
||||
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 void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
|
||||
{
|
||||
var progress = Math.Clamp(notification.Progress, 0f, 1f);
|
||||
var progressBarColor = UIColors.Get("LightlessGreen");
|
||||
var progressHeight = 3f;
|
||||
// Position above the duration bar (2px duration bar + 1px spacing)
|
||||
var progressY = windowPos.Y + windowSize.Y - progressHeight - 3f;
|
||||
var progressWidth = windowSize.X * progress;
|
||||
|
||||
DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha);
|
||||
|
||||
if (progress > 0)
|
||||
{
|
||||
DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateDurationProgress(LightlessNotification notification)
|
||||
{
|
||||
// Calculate duration timer progress
|
||||
var elapsed = DateTime.UtcNow - notification.CreatedAt;
|
||||
return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds));
|
||||
}
|
||||
|
||||
private void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha)
|
||||
{
|
||||
var bgProgressColor = new Vector4(progressBarColor.X * 0.3f, progressBarColor.Y * 0.3f, progressBarColor.Z * 0.3f, 0.5f * alpha);
|
||||
drawList.AddRectFilled(
|
||||
new Vector2(windowPos.X, progressY),
|
||||
new Vector2(windowPos.X + windowSize.X, progressY + progressHeight),
|
||||
ImGui.ColorConvertFloat4ToU32(bgProgressColor),
|
||||
0f
|
||||
);
|
||||
}
|
||||
|
||||
private void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha)
|
||||
{
|
||||
var progressColor = progressBarColor;
|
||||
progressColor.W *= alpha;
|
||||
drawList.AddRectFilled(
|
||||
new Vector2(windowPos.X, progressY),
|
||||
new Vector2(windowPos.X + progressWidth, progressY + progressHeight),
|
||||
ImGui.ColorConvertFloat4ToU32(progressColor),
|
||||
0f
|
||||
);
|
||||
}
|
||||
|
||||
private void DrawNotificationText(LightlessNotification notification, float alpha)
|
||||
{
|
||||
var contentPos = new Vector2(_contentPaddingX, _contentPaddingY);
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
var contentWidth = CalculateContentWidth(windowSize.X);
|
||||
|
||||
ImGui.SetCursorPos(contentPos);
|
||||
|
||||
var titleHeight = DrawTitle(notification, contentWidth, alpha);
|
||||
DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha);
|
||||
|
||||
if (HasActions(notification))
|
||||
{
|
||||
PositionActionsAtBottom(windowSize.Y);
|
||||
DrawNotificationActions(notification, contentWidth, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateContentWidth(float windowWidth) =>
|
||||
windowWidth - (_contentPaddingX * 2);
|
||||
|
||||
private bool HasActions(LightlessNotification notification) =>
|
||||
notification.Actions.Count > 0;
|
||||
|
||||
private void PositionActionsAtBottom(float windowHeight)
|
||||
{
|
||||
var actionHeight = ImGui.GetFrameHeight();
|
||||
var bottomY = windowHeight - _contentPaddingY - actionHeight;
|
||||
ImGui.SetCursorPosY(bottomY);
|
||||
ImGui.SetCursorPosX(_contentPaddingX);
|
||||
}
|
||||
|
||||
private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha)
|
||||
{
|
||||
var titleColor = new Vector4(1f, 1f, 1f, alpha);
|
||||
var titleText = FormatTitleText(notification);
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
|
||||
{
|
||||
return DrawWrappedText(titleText, contentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatTitleText(LightlessNotification notification)
|
||||
{
|
||||
if (!_configService.Current.ShowNotificationTimestamp)
|
||||
return notification.Title;
|
||||
|
||||
var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss");
|
||||
return $"[{timestamp}] {notification.Title}";
|
||||
}
|
||||
|
||||
private float DrawWrappedText(string text, float wrapWidth)
|
||||
{
|
||||
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth);
|
||||
var startY = ImGui.GetCursorPosY();
|
||||
ImGui.TextWrapped(text);
|
||||
var height = ImGui.GetCursorPosY() - startY;
|
||||
ImGui.PopTextWrapPos();
|
||||
return height;
|
||||
}
|
||||
|
||||
private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha)
|
||||
{
|
||||
if (string.IsNullOrEmpty(notification.Message)) return;
|
||||
|
||||
var messagePos = contentPos + new Vector2(0f, titleHeight + _titleMessageSpacing);
|
||||
var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha);
|
||||
|
||||
ImGui.SetCursorPos(messagePos);
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, messageColor))
|
||||
{
|
||||
DrawWrappedText(notification.Message, contentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha)
|
||||
{
|
||||
var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth);
|
||||
|
||||
_logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}",
|
||||
notification.Actions.Count, buttonWidth, availableWidth);
|
||||
|
||||
var startX = ImGui.GetCursorPosX();
|
||||
|
||||
for (int i = 0; i < notification.Actions.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
PositionActionButton(i, startX, 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)
|
||||
{
|
||||
_logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth);
|
||||
|
||||
var buttonColor = action.Color;
|
||||
buttonColor.W *= alpha;
|
||||
|
||||
var hoveredColor = buttonColor * 1.1f;
|
||||
hoveredColor.W = buttonColor.W;
|
||||
|
||||
var activeColor = buttonColor * 0.9f;
|
||||
activeColor.W = buttonColor.W;
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, buttonColor))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, hoveredColor))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, activeColor))
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha)))
|
||||
{
|
||||
var buttonPressed = false;
|
||||
|
||||
if (action.Icon != FontAwesomeIcon.None)
|
||||
{
|
||||
buttonPressed = DrawIconTextButton(action.Icon, action.Label, buttonWidth, alpha);
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonPressed = ImGui.Button(action.Label, new Vector2(buttonWidth, 0));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed);
|
||||
|
||||
if (buttonPressed)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Executing action: {ActionId}", action.Id);
|
||||
action.OnClick(notification);
|
||||
_logger.LogDebug("Action executed successfully: {ActionId}", action.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing notification action: {ActionId}", action.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool DrawIconTextButton(FontAwesomeIcon icon, string text, float width, float alpha)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var cursorPos = ImGui.GetCursorScreenPos();
|
||||
var frameHeight = ImGui.GetFrameHeight();
|
||||
|
||||
Vector2 iconSize;
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
iconSize = ImGui.CalcTextSize(icon.ToIconString());
|
||||
}
|
||||
|
||||
var textSize = ImGui.CalcTextSize(text);
|
||||
var spacing = 3f * ImGuiHelpers.GlobalScale;
|
||||
var totalTextWidth = iconSize.X + spacing + textSize.X;
|
||||
|
||||
var buttonPressed = ImGui.InvisibleButton($"btn_{icon}_{text}", new Vector2(width, frameHeight));
|
||||
|
||||
var buttonMin = ImGui.GetItemRectMin();
|
||||
var buttonMax = ImGui.GetItemRectMax();
|
||||
var buttonSize = buttonMax - buttonMin;
|
||||
|
||||
var buttonColor = ImGui.GetColorU32(ImGuiCol.Button);
|
||||
if (ImGui.IsItemHovered())
|
||||
buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonHovered);
|
||||
if (ImGui.IsItemActive())
|
||||
buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonActive);
|
||||
|
||||
drawList.AddRectFilled(buttonMin, buttonMax, buttonColor, 3f);
|
||||
|
||||
var iconPos = buttonMin + new Vector2((buttonSize.X - totalTextWidth) / 2f, (buttonSize.Y - iconSize.Y) / 2f);
|
||||
var textPos = iconPos + new Vector2(iconSize.X + spacing, (iconSize.Y - textSize.Y) / 2f);
|
||||
|
||||
var textColor = ImGui.GetColorU32(ImGuiCol.Text);
|
||||
|
||||
// Draw icon
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
drawList.AddText(iconPos, textColor, icon.ToIconString());
|
||||
}
|
||||
|
||||
// Draw text
|
||||
drawList.AddText(textPos, textColor, text);
|
||||
|
||||
return buttonPressed;
|
||||
}
|
||||
|
||||
private float CalculateNotificationHeight(LightlessNotification notification)
|
||||
{
|
||||
var contentWidth = CalculateContentWidth(_configService.Current.NotificationWidth);
|
||||
var height = 12f;
|
||||
|
||||
height += CalculateTitleHeight(notification, contentWidth);
|
||||
height += CalculateMessageHeight(notification, contentWidth);
|
||||
|
||||
if (notification.ShowProgress)
|
||||
{
|
||||
height += 12f;
|
||||
}
|
||||
|
||||
if (notification.Actions.Count > 0)
|
||||
{
|
||||
height += ImGui.GetStyle().ItemSpacing.Y;
|
||||
height += ImGui.GetFrameHeight();
|
||||
height += 12f;
|
||||
}
|
||||
|
||||
return Math.Clamp(height, _notificationMinHeight, _notificationMaxHeight);
|
||||
}
|
||||
|
||||
private float CalculateTitleHeight(LightlessNotification notification, float contentWidth)
|
||||
{
|
||||
var titleText = _configService.Current.ShowNotificationTimestamp
|
||||
? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}"
|
||||
: notification.Title;
|
||||
|
||||
return ImGui.CalcTextSize(titleText, true, contentWidth).Y;
|
||||
}
|
||||
|
||||
private float CalculateMessageHeight(LightlessNotification notification, float contentWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(notification.Message)) return 0f;
|
||||
|
||||
var messageHeight = ImGui.CalcTextSize(notification.Message, true, contentWidth).Y;
|
||||
return 4f + messageHeight;
|
||||
}
|
||||
|
||||
private Vector4 GetNotificationAccentColor(NotificationType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
NotificationType.Info => UIColors.Get("LightlessPurple"),
|
||||
NotificationType.Warning => UIColors.Get("LightlessYellow"),
|
||||
NotificationType.Error => UIColors.Get("DimRed"),
|
||||
NotificationType.PairRequest => UIColors.Get("LightlessBlue"),
|
||||
NotificationType.Download => UIColors.Get("LightlessGreen"),
|
||||
NotificationType.Performance => UIColors.Get("LightlessOrange"),
|
||||
|
||||
_ => UIColors.Get("LightlessPurple")
|
||||
};
|
||||
}
|
||||
}
|
||||
43
LightlessSync/UI/Models/Changelog.cs
Normal file
43
LightlessSync/UI/Models/Changelog.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace LightlessSync.UI.Models
|
||||
{
|
||||
public class ChangelogFile
|
||||
{
|
||||
public string Tagline { get; init; } = string.Empty;
|
||||
public string Subline { get; init; } = string.Empty;
|
||||
public List<ChangelogEntry> Changelog { get; init; } = new();
|
||||
public List<CreditCategory>? Credits { get; init; }
|
||||
}
|
||||
|
||||
public class ChangelogEntry
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Date { get; init; } = string.Empty;
|
||||
public string Tagline { get; init; } = string.Empty;
|
||||
public bool? IsCurrent { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public List<ChangelogVersion>? Versions { get; init; }
|
||||
}
|
||||
|
||||
public class ChangelogVersion
|
||||
{
|
||||
public string Number { get; init; } = string.Empty;
|
||||
public List<string> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public class CreditCategory
|
||||
{
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public List<CreditItem> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public class CreditItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Role { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CreditsFile
|
||||
{
|
||||
public List<CreditCategory> Credits { get; init; } = new();
|
||||
}
|
||||
}
|
||||
6
LightlessSync/UI/Models/GroupFolder.cs
Normal file
6
LightlessSync/UI/Models/GroupFolder.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.UI.Components;
|
||||
|
||||
namespace LightlessSync.UI.Models;
|
||||
|
||||
public record GroupFolder(GroupFullInfoDto GroupFullInfo, IDrawFolder GroupDrawFolder);
|
||||
32
LightlessSync/UI/Models/LightlessNotification.cs
Normal file
32
LightlessSync/UI/Models/LightlessNotification.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using System.Numerics;
|
||||
namespace LightlessSync.UI.Models;
|
||||
public class LightlessNotification
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public NotificationType Type { get; set; } = NotificationType.Info;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(5);
|
||||
public bool IsExpired => DateTime.UtcNow - CreatedAt > Duration;
|
||||
public bool IsDismissed { get; set; } = false;
|
||||
public List<LightlessNotificationAction> Actions { get; set; } = new();
|
||||
public bool ShowProgress { get; set; } = false;
|
||||
public float Progress { get; set; } = 0f;
|
||||
public float AnimationProgress { get; set; } = 0f;
|
||||
public bool IsAnimatingIn { get; set; } = true;
|
||||
public bool IsAnimatingOut { get; set; } = false;
|
||||
public uint? SoundEffectId { get; set; } = null;
|
||||
}
|
||||
public class LightlessNotificationAction
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public FontAwesomeIcon Icon { get; set; } = FontAwesomeIcon.None;
|
||||
public Vector4 Color { get; set; } = Vector4.One;
|
||||
public Action<LightlessNotification> OnClick { get; set; } = _ => { };
|
||||
public bool IsPrimary { get; set; } = false;
|
||||
public bool IsDestructive { get; set; } = false;
|
||||
}
|
||||
72
LightlessSync/UI/Models/NotificationSounds.cs
Normal file
72
LightlessSync/UI/Models/NotificationSounds.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
|
||||
namespace LightlessSync.UI.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Common FFXIV <se.#> sound effect IDs for notifications.
|
||||
/// These correspond to the same sound IDs used in macros (1–16).
|
||||
/// </summary>
|
||||
public static class NotificationSounds
|
||||
{
|
||||
// ─────────────────────────────────────────────
|
||||
// Base <se.#> IDs (1–16)
|
||||
// https://ffxiv.consolegameswiki.com/wiki/Macros#Sound_Effects
|
||||
// ─────────────────────────────────────────────
|
||||
public const uint Se1 = 1; // Soft chime
|
||||
public const uint Se2 = 2; // Higher chime
|
||||
public const uint Se3 = 3; // Bell tone
|
||||
public const uint Se4 = 4; // Harp tone
|
||||
public const uint Se5 = 5; // Mechanical click
|
||||
public const uint Se6 = 6; // Drum / percussion
|
||||
public const uint Se7 = 7; // Metallic chime
|
||||
public const uint Se8 = 8; // Wooden tone
|
||||
public const uint Se9 = 9; // Wind / flute tone
|
||||
public const uint Se10 = 11; // Magical sparkle (ID 10 is skipped in game)
|
||||
public const uint Se11 = 12; // Metallic ring
|
||||
public const uint Se12 = 13; // Deep thud
|
||||
public const uint Se13 = 14; // "Tell received" ping
|
||||
public const uint Se14 = 15; // Success fanfare
|
||||
public const uint Se15 = 16; // System warning
|
||||
// Note: Se16 doesn't exist - Se15 is the last available sound
|
||||
|
||||
/// <summary>
|
||||
/// General notification sound (<se.2>)
|
||||
/// </summary>
|
||||
public const uint Info = Se2;
|
||||
|
||||
/// <summary>
|
||||
/// Warning/alert sound (<se.15>)
|
||||
/// </summary>
|
||||
public const uint Warning = Se15;
|
||||
|
||||
/// <summary>
|
||||
/// Error sound (<se.15> - System warning, used for errors)
|
||||
/// </summary>
|
||||
public const uint Error = Se15;
|
||||
|
||||
/// <summary>
|
||||
/// Success sound (<se.14>)
|
||||
/// </summary>
|
||||
public const uint Success = Se14;
|
||||
|
||||
/// <summary>
|
||||
/// Pair request sound (<se.13>, same as tell notification)
|
||||
/// </summary>
|
||||
public const uint PairRequest = Se13;
|
||||
|
||||
/// <summary>
|
||||
/// Download complete sound (<se.10>, a clean sparkle tone)
|
||||
/// </summary>
|
||||
public const uint DownloadComplete = Se10;
|
||||
|
||||
/// <summary>
|
||||
/// Get default sound for notification type
|
||||
/// </summary>
|
||||
public static uint GetDefaultSound(NotificationType type) => type switch
|
||||
{
|
||||
NotificationType.Info => Info,
|
||||
NotificationType.Warning => Warning,
|
||||
NotificationType.Error => Error,
|
||||
_ => Info
|
||||
};
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
||||
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(_pair.UserData);
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(_pair.UserData);
|
||||
|
||||
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
|
||||
{
|
||||
|
||||
12
LightlessSync/UI/ProfileTags.cs
Normal file
12
LightlessSync/UI/ProfileTags.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LightlessSync.UI
|
||||
{
|
||||
public enum ProfileTags
|
||||
{
|
||||
SFW = 0,
|
||||
NSFW = 1,
|
||||
RP = 2,
|
||||
ERP = 3,
|
||||
Venues = 4,
|
||||
Gpose = 5
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
||||
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(Pair.UserData);
|
||||
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(Pair.UserData);
|
||||
|
||||
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
|
||||
{
|
||||
|
||||
64
LightlessSync/UI/Style/Luminance.cs
Normal file
64
LightlessSync/UI/Style/Luminance.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI.Style
|
||||
{
|
||||
internal static class Luminance
|
||||
{
|
||||
public static float BrightnessThreshold { get; set; } = 0.4f;
|
||||
public static float HighlightBoostMax { get; set; } = 0.1f;
|
||||
public static float SmoothFactor { get; set; } = 0.15f;
|
||||
|
||||
private static float Brightness(Vector4 color)
|
||||
=> Math.Max(color.X, Math.Max(color.Y, color.Z));
|
||||
|
||||
public static float ComputeHighlight(Vector4? textColor, Vector4? glowColor)
|
||||
{
|
||||
float brightnessText = textColor.HasValue ? Brightness(textColor.Value) : 1f;
|
||||
float brightnessGlow = glowColor.HasValue ? Brightness(glowColor.Value) : 1f;
|
||||
|
||||
if (brightnessText >= BrightnessThreshold || brightnessGlow >= BrightnessThreshold)
|
||||
return 0f;
|
||||
|
||||
float deficit = Math.Min(BrightnessThreshold - brightnessText,
|
||||
BrightnessThreshold - brightnessGlow);
|
||||
|
||||
float factor = Math.Clamp(deficit / BrightnessThreshold, 0f, 1f);
|
||||
factor = MathF.Pow(factor, 2.0f);
|
||||
|
||||
return factor * HighlightBoostMax;
|
||||
}
|
||||
|
||||
public static Vector4 BackgroundContrast(Vector4? textColor, Vector4? glowColor, Vector4 backgroundColor, ref Vector4 currentBg)
|
||||
{
|
||||
if (!textColor.HasValue && !glowColor.HasValue)
|
||||
return backgroundColor;
|
||||
|
||||
float brightnessText = textColor.HasValue ? Brightness(textColor.Value) : 0f;
|
||||
float brightnessGlow = glowColor.HasValue ? Brightness(glowColor.Value) : 0f;
|
||||
|
||||
float fgBrightness = Math.Max(brightnessText, brightnessGlow);
|
||||
float bgBrightness = Brightness(backgroundColor);
|
||||
float diff = Math.Abs(bgBrightness - fgBrightness);
|
||||
|
||||
bool shouldBeDark = fgBrightness > 0.5f;
|
||||
Vector4 targetBg;
|
||||
|
||||
if (diff >= BrightnessThreshold)
|
||||
{
|
||||
targetBg = backgroundColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetBg = shouldBeDark
|
||||
? new Vector4(0.05f, 0.05f, 0.05f, backgroundColor.W)
|
||||
: new Vector4(0.95f, 0.95f, 0.95f, backgroundColor.W);
|
||||
}
|
||||
|
||||
float t = Math.Clamp(SmoothFactor, 0f, 1f);
|
||||
currentBg = t <= 0f ? targetBg : Vector4.Lerp(currentBg, targetBg, t);
|
||||
|
||||
return currentBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
231
LightlessSync/UI/Style/MainStyle.cs
Normal file
231
LightlessSync/UI/Style/MainStyle.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
// inspiration: brio because it's style is fucking amazing
|
||||
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI.Style;
|
||||
|
||||
internal static class MainStyle
|
||||
{
|
||||
public readonly record struct StyleColorOption(string Key, string Label, Func<Vector4> DefaultValue, ImGuiCol Target, string? Description = null, string? UiColorKey = null);
|
||||
public readonly record struct StyleFloatOption(string Key, string Label, float DefaultValue, ImGuiStyleVar Target, float? Min = null, float? Max = null, float Speed = 0.25f, string? Description = null);
|
||||
public readonly record struct StyleVector2Option(string Key, string Label, Func<Vector2> DefaultValue, ImGuiStyleVar Target, Vector2? Min = null, Vector2? Max = null, float Speed = 0.25f, string? Description = null);
|
||||
|
||||
private static LightlessConfigService? _config;
|
||||
private static UiThemeConfigService? _themeConfig;
|
||||
public static void Init(LightlessConfigService config, UiThemeConfigService themeConfig)
|
||||
{
|
||||
_config = config;
|
||||
_themeConfig = themeConfig;
|
||||
}
|
||||
public static bool ShouldUseTheme => _config?.Current.UseLightlessRedesign ?? false;
|
||||
|
||||
private static bool _hasPushed;
|
||||
private static int _pushedColorCount;
|
||||
private static int _pushedStyleVarCount;
|
||||
|
||||
private static readonly StyleColorOption[] _colorOptions =
|
||||
[
|
||||
new("color.text", "Text", () => Rgba(255, 255, 255, 255), ImGuiCol.Text),
|
||||
new("color.textDisabled", "Text (Disabled)", () => Rgba(128, 128, 128, 255), ImGuiCol.TextDisabled),
|
||||
new("color.windowBg", "Window Background", () => Rgba(23, 23, 23, 248), ImGuiCol.WindowBg),
|
||||
new("color.childBg", "Child Background", () => Rgba(23, 23, 23, 66), ImGuiCol.ChildBg),
|
||||
new("color.popupBg", "Popup Background", () => Rgba(23, 23, 23, 248), ImGuiCol.PopupBg),
|
||||
new("color.border", "Border", () => Rgba(65, 65, 65, 255), ImGuiCol.Border),
|
||||
new("color.borderShadow", "Border Shadow", () => Rgba(0, 0, 0, 150), ImGuiCol.BorderShadow),
|
||||
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
|
||||
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 255), ImGuiCol.FrameBgHovered),
|
||||
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
|
||||
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
|
||||
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
|
||||
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed),
|
||||
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
|
||||
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
|
||||
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),
|
||||
new("color.scrollbarGrabHovered", "Scrollbar Grab (Hover)", () => Rgba(70, 70, 70, 255), ImGuiCol.ScrollbarGrabHovered),
|
||||
new("color.scrollbarGrabActive", "Scrollbar Grab (Active)", () => Rgba(70, 70, 70, 255), ImGuiCol.ScrollbarGrabActive),
|
||||
new("color.checkMark", "Check Mark", () => UIColors.Get("LightlessPurple"), ImGuiCol.CheckMark, UiColorKey: "LightlessPurple"),
|
||||
new("color.sliderGrab", "Slider Grab", () => Rgba(101, 101, 101, 255), ImGuiCol.SliderGrab),
|
||||
new("color.sliderGrabActive", "Slider Grab (Active)", () => Rgba(123, 123, 123, 255), ImGuiCol.SliderGrabActive),
|
||||
new("color.button", "Button", () => UIColors.Get("ButtonDefault"), ImGuiCol.Button, UiColorKey: "ButtonDefault"),
|
||||
new("color.buttonHovered", "Button (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.ButtonHovered, UiColorKey: "LightlessPurple"),
|
||||
new("color.buttonActive", "Button (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.ButtonActive, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.header", "Header", () => Rgba(0, 0, 0, 60), ImGuiCol.Header),
|
||||
new("color.headerHovered", "Header (Hover)", () => Rgba(0, 0, 0, 90), ImGuiCol.HeaderHovered),
|
||||
new("color.headerActive", "Header (Active)", () => Rgba(0, 0, 0, 120), ImGuiCol.HeaderActive),
|
||||
new("color.separator", "Separator", () => Rgba(75, 75, 75, 121), ImGuiCol.Separator),
|
||||
new("color.separatorHovered", "Separator (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.SeparatorHovered, UiColorKey: "LightlessPurple"),
|
||||
new("color.separatorActive", "Separator (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.SeparatorActive, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.resizeGrip", "Resize Grip", () => Rgba(0, 0, 0, 0), ImGuiCol.ResizeGrip),
|
||||
new("color.resizeGripHovered", "Resize Grip (Hover)", () => Rgba(0, 0, 0, 0), ImGuiCol.ResizeGripHovered),
|
||||
new("color.resizeGripActive", "Resize Grip (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.ResizeGripActive, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.tab", "Tab", () => Rgba(40, 40, 40, 255), ImGuiCol.Tab),
|
||||
new("color.tabHovered", "Tab (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.TabHovered, UiColorKey: "LightlessPurple"),
|
||||
new("color.tabActive", "Tab (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.TabActive, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.tabUnfocused", "Tab (Unfocused)", () => Rgba(40, 40, 40, 255), ImGuiCol.TabUnfocused),
|
||||
new("color.tabUnfocusedActive", "Tab (Unfocused Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.TabUnfocusedActive, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.dockingPreview", "Docking Preview", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.DockingPreview, UiColorKey: "LightlessPurpleActive"),
|
||||
new("color.dockingEmptyBg", "Docking Empty Background", () => Rgba(50, 50, 50, 255), ImGuiCol.DockingEmptyBg),
|
||||
new("color.plotLines", "Plot Lines", () => Rgba(150, 150, 150, 255), ImGuiCol.PlotLines),
|
||||
new("color.tableHeaderBg", "Table Header Background", () => Rgba(48, 48, 48, 255), ImGuiCol.TableHeaderBg),
|
||||
new("color.tableBorderStrong", "Table Border Strong", () => Rgba(79, 79, 89, 255), ImGuiCol.TableBorderStrong),
|
||||
new("color.tableBorderLight", "Table Border Light", () => Rgba(59, 59, 64, 255), ImGuiCol.TableBorderLight),
|
||||
new("color.tableRowBg", "Table Row Background", () => Rgba(0, 0, 0, 0), ImGuiCol.TableRowBg),
|
||||
new("color.tableRowBgAlt", "Table Row Background (Alt)", () => Rgba(255, 255, 255, 15), ImGuiCol.TableRowBgAlt),
|
||||
new("color.textSelectedBg", "Text Selection Background", () => Rgba(173, 138, 245, 255), ImGuiCol.TextSelectedBg),
|
||||
new("color.dragDropTarget", "Drag & Drop Target", () => Rgba(173, 138, 245, 255), ImGuiCol.DragDropTarget),
|
||||
new("color.navHighlight", "Navigation Highlight", () => Rgba(173, 138, 245, 179), ImGuiCol.NavHighlight),
|
||||
new("color.navWindowingDimBg", "Navigation Window Dim", () => Rgba(204, 204, 204, 51), ImGuiCol.NavWindowingDimBg),
|
||||
new("color.navWindowingHighlight", "Navigation Window Highlight", () => Rgba(204, 204, 204, 89), ImGuiCol.NavWindowingHighlight)
|
||||
];
|
||||
|
||||
private static readonly StyleVector2Option[] _vector2Options =
|
||||
[
|
||||
new("vector.windowPadding", "Window Padding", () => new Vector2(6f, 6f), ImGuiStyleVar.WindowPadding),
|
||||
new("vector.framePadding", "Frame Padding", () => new Vector2(4f, 3f), ImGuiStyleVar.FramePadding),
|
||||
new("vector.cellPadding", "Cell Padding", () => new Vector2(4f, 4f), ImGuiStyleVar.CellPadding),
|
||||
new("vector.itemSpacing", "Item Spacing", () => new Vector2(4f, 4f), ImGuiStyleVar.ItemSpacing),
|
||||
new("vector.itemInnerSpacing", "Item Inner Spacing", () => new Vector2(4f, 4f), ImGuiStyleVar.ItemInnerSpacing)
|
||||
];
|
||||
|
||||
private static readonly StyleFloatOption[] _floatOptions =
|
||||
[
|
||||
new("float.indentSpacing", "Indent Spacing", 21f, ImGuiStyleVar.IndentSpacing, 0f, 100f, 0.5f),
|
||||
new("float.scrollbarSize", "Scrollbar Size", 10f, ImGuiStyleVar.ScrollbarSize, 4f, 30f, 0.5f),
|
||||
new("float.grabMinSize", "Grab Minimum Size", 20f, ImGuiStyleVar.GrabMinSize, 1f, 80f, 0.5f),
|
||||
new("float.windowBorderSize", "Window Border Size", 1.5f, ImGuiStyleVar.WindowBorderSize, 0f, 5f, 0.1f),
|
||||
new("float.childBorderSize", "Child Border Size", 1.5f, ImGuiStyleVar.ChildBorderSize, 0f, 5f, 0.1f),
|
||||
new("float.popupBorderSize", "Popup Border Size", 1.5f, ImGuiStyleVar.PopupBorderSize, 0f, 5f, 0.1f),
|
||||
new("float.frameBorderSize", "Frame Border Size", 0f, ImGuiStyleVar.FrameBorderSize, 0f, 5f, 0.1f),
|
||||
new("float.windowRounding", "Window Rounding", 7f, ImGuiStyleVar.WindowRounding, 0f, 20f, 0.2f),
|
||||
new("float.childRounding", "Child Rounding", 4f, ImGuiStyleVar.ChildRounding, 0f, 20f, 0.2f),
|
||||
new("float.frameRounding", "Frame Rounding", 4f, ImGuiStyleVar.FrameRounding, 0f, 20f, 0.2f),
|
||||
new("float.popupRounding", "Popup Rounding", 4f, ImGuiStyleVar.PopupRounding, 0f, 20f, 0.2f),
|
||||
new("float.scrollbarRounding", "Scrollbar Rounding", 4f, ImGuiStyleVar.ScrollbarRounding, 0f, 20f, 0.2f),
|
||||
new("float.grabRounding", "Grab Rounding", 4f, ImGuiStyleVar.GrabRounding, 0f, 20f, 0.2f),
|
||||
new("float.tabRounding", "Tab Rounding", 4f, ImGuiStyleVar.TabRounding, 0f, 20f, 0.2f)
|
||||
];
|
||||
|
||||
public static IReadOnlyList<StyleColorOption> ColorOptions => _colorOptions;
|
||||
public static IReadOnlyList<StyleFloatOption> FloatOptions => _floatOptions;
|
||||
public static IReadOnlyList<StyleVector2Option> Vector2Options => _vector2Options;
|
||||
|
||||
public static void PushStyle()
|
||||
{
|
||||
if (_hasPushed)
|
||||
PopStyle();
|
||||
|
||||
if (!ShouldUseTheme)
|
||||
{
|
||||
_hasPushed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_hasPushed = true;
|
||||
_pushedColorCount = 0;
|
||||
_pushedStyleVarCount = 0;
|
||||
|
||||
foreach (var option in _colorOptions)
|
||||
Push(option.Target, ResolveColor(option));
|
||||
|
||||
foreach (var option in _vector2Options)
|
||||
PushStyleVar(option.Target, ResolveVector(option));
|
||||
|
||||
foreach (var option in _floatOptions)
|
||||
PushStyleVar(option.Target, ResolveFloat(option));
|
||||
}
|
||||
|
||||
public static void PopStyle()
|
||||
{
|
||||
if (!_hasPushed)
|
||||
return;
|
||||
|
||||
if (_pushedStyleVarCount > 0)
|
||||
ImGui.PopStyleVar(_pushedStyleVarCount);
|
||||
if (_pushedColorCount > 0)
|
||||
ImGui.PopStyleColor(_pushedColorCount);
|
||||
|
||||
_hasPushed = false;
|
||||
_pushedColorCount = 0;
|
||||
_pushedStyleVarCount = 0;
|
||||
}
|
||||
|
||||
private static Vector4 ResolveColor(StyleColorOption option)
|
||||
{
|
||||
var defaultValue = NormalizeColorVector(option.DefaultValue());
|
||||
if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Color is { } packed)
|
||||
return PackedColorToVector4(packed);
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static Vector2 ResolveVector(StyleVector2Option option)
|
||||
{
|
||||
var value = option.DefaultValue();
|
||||
if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Vector2 is { } vectorOverride)
|
||||
{
|
||||
value = vectorOverride;
|
||||
}
|
||||
|
||||
if (option.Min is { } min)
|
||||
value = Vector2.Max(value, min);
|
||||
if (option.Max is { } max)
|
||||
value = Vector2.Min(value, max);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float ResolveFloat(StyleFloatOption option)
|
||||
{
|
||||
var value = option.DefaultValue;
|
||||
if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Float is { } floatOverride)
|
||||
{
|
||||
value = floatOverride;
|
||||
}
|
||||
|
||||
if (option.Min.HasValue)
|
||||
value = MathF.Max(option.Min.Value, value);
|
||||
if (option.Max.HasValue)
|
||||
value = MathF.Min(option.Max.Value, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Push(ImGuiCol col, Vector4 rgba)
|
||||
{
|
||||
rgba = NormalizeColorVector(rgba);
|
||||
ImGui.PushStyleColor(col, rgba);
|
||||
_pushedColorCount++;
|
||||
}
|
||||
|
||||
private static void PushStyleVar(ImGuiStyleVar var, float value)
|
||||
{
|
||||
ImGui.PushStyleVar(var, value);
|
||||
_pushedStyleVarCount++;
|
||||
}
|
||||
|
||||
private static void PushStyleVar(ImGuiStyleVar var, Vector2 value)
|
||||
{
|
||||
ImGui.PushStyleVar(var, value);
|
||||
_pushedStyleVarCount++;
|
||||
}
|
||||
|
||||
private static Vector4 Rgba(byte r, byte g, byte b, byte a = 255)
|
||||
=> new Vector4(r / 255f, g / 255f, b / 255f, a / 255f);
|
||||
|
||||
internal static Vector4 NormalizeColorVector(Vector4 rgba)
|
||||
{
|
||||
if (rgba.X > 1f || rgba.Y > 1f || rgba.Z > 1f || rgba.W > 1f)
|
||||
rgba /= 255f;
|
||||
return rgba;
|
||||
}
|
||||
|
||||
internal static Vector4 PackedColorToVector4(uint color)
|
||||
=> new(
|
||||
(color & 0xFF) / 255f,
|
||||
((color >> 8) & 0xFF) / 255f,
|
||||
((color >> 16) & 0xFF) / 255f,
|
||||
((color >> 24) & 0xFF) / 255f);
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Handlers;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
@@ -22,29 +31,51 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
private readonly bool _isOwner = false;
|
||||
private readonly List<string> _oneTimeInvites = [];
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private List<BannedGroupUserDto> _bannedUsers = [];
|
||||
private LightlessGroupProfileData? _profileData = null;
|
||||
private bool _adjustedForScollBarsLocalProfile = false;
|
||||
private bool _adjustedForScollBarsOnlineProfile = false;
|
||||
private string _descriptionText = string.Empty;
|
||||
private IDalamudTextureWrap? _pfpTextureWrap;
|
||||
private string _profileDescription = string.Empty;
|
||||
private byte[] _profileImage = [];
|
||||
private bool _showFileDialogError = false;
|
||||
private int _multiInvites;
|
||||
private string _newPassword;
|
||||
private bool _pwChangeSuccess;
|
||||
private Task<int>? _pruneTestTask;
|
||||
private Task<int>? _pruneTask;
|
||||
private int _pruneDays = 14;
|
||||
private List<int> _selectedTags = [];
|
||||
|
||||
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
|
||||
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService)
|
||||
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
|
||||
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
|
||||
{
|
||||
GroupFullInfo = groupFullInfo;
|
||||
_apiController = apiController;
|
||||
_uiSharedService = uiSharedService;
|
||||
_pairManager = pairManager;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_fileDialogManager = fileDialogManager;
|
||||
|
||||
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
|
||||
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
|
||||
_newPassword = string.Empty;
|
||||
_multiInvites = 30;
|
||||
_pwChangeSuccess = true;
|
||||
IsOpen = true;
|
||||
Mediator.Subscribe<ClearProfileGroupDataMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal))
|
||||
{
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = null;
|
||||
}
|
||||
});
|
||||
SizeConstraints = new WindowSizeConstraints()
|
||||
{
|
||||
MinimumSize = new(700, 500),
|
||||
@@ -58,10 +89,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
if (!_isModerator && !_isOwner) return;
|
||||
|
||||
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
|
||||
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group];
|
||||
|
||||
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
|
||||
_profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
|
||||
GetTagsFromProfile();
|
||||
|
||||
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
|
||||
using (_uiSharedService.UidFont.Push())
|
||||
_uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue"));
|
||||
|
||||
@@ -69,14 +103,16 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
var perm = GroupFullInfo.GroupPermissions;
|
||||
|
||||
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
|
||||
|
||||
|
||||
if (tabbar)
|
||||
{
|
||||
DrawInvites(perm);
|
||||
|
||||
DrawManagement();
|
||||
|
||||
DrawPermission(perm);
|
||||
DrawPermission(perm);
|
||||
|
||||
DrawProfile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +212,184 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
ownerTab.Dispose();
|
||||
}
|
||||
}
|
||||
private void DrawProfile()
|
||||
{
|
||||
var profileTab = ImRaii.TabItem("Profile");
|
||||
|
||||
if (profileTab)
|
||||
{
|
||||
if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
|
||||
if (!_profileImage.SequenceEqual(_profileData.ImageData.Value))
|
||||
{
|
||||
_profileImage = _profileData.ImageData.Value;
|
||||
_pfpTextureWrap?.Dispose();
|
||||
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
|
||||
}
|
||||
|
||||
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_profileDescription = _profileData.Description;
|
||||
_descriptionText = _profileDescription;
|
||||
}
|
||||
|
||||
if (_pfpTextureWrap != null)
|
||||
{
|
||||
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
|
||||
}
|
||||
|
||||
var spacing = ImGui.GetStyle().ItemSpacing.X;
|
||||
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f);
|
||||
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
|
||||
if (descriptionTextSize.Y > childFrame.Y)
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_adjustedForScollBarsOnlineProfile = false;
|
||||
}
|
||||
childFrame = childFrame with
|
||||
{
|
||||
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(101, childFrame))
|
||||
{
|
||||
UiSharedService.TextWrapped(_profileData.Description);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
ImGui.TreePop();
|
||||
}
|
||||
var nsfw = _profileData.IsNsfw;
|
||||
ImGui.BeginDisabled();
|
||||
ImGui.Checkbox("Is NSFW", ref nsfw);
|
||||
ImGui.EndDisabled();
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
ImGui.Dummy(new Vector2(5));
|
||||
ImGui.TextUnformatted($"Profile Picture:");
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
|
||||
{
|
||||
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
|
||||
{
|
||||
if (!success) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false);
|
||||
MemoryStream ms = new(fileContent);
|
||||
await using (ms.ConfigureAwait(false))
|
||||
{
|
||||
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
|
||||
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
}
|
||||
using var image = Image.Load<Rgba32>(fileContent);
|
||||
|
||||
if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024))
|
||||
{
|
||||
_showFileDialogError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_showFileDialogError = false;
|
||||
await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), BannerBase64: null, IsNsfw: null, IsDisabled: null))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
UiSharedService.AttachToolTip("Select and upload a new profile picture");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
|
||||
if (_showFileDialogError)
|
||||
{
|
||||
UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed);
|
||||
}
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Tags:");
|
||||
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
|
||||
|
||||
var allCategoryIndexes = Enum.GetValues<ProfileTags>()
|
||||
.Cast<int>()
|
||||
.ToList();
|
||||
|
||||
foreach(int tag in allCategoryIndexes)
|
||||
{
|
||||
using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag);
|
||||
}
|
||||
ImGui.Separator();
|
||||
var widthTextBox = 400;
|
||||
var posX = ImGui.GetCursorPosX();
|
||||
ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500");
|
||||
ImGui.SetCursorPosX(posX);
|
||||
ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X);
|
||||
ImGui.TextUnformatted("Preview (approximate)");
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200));
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
using (_uiSharedService.GameFont.Push())
|
||||
{
|
||||
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
|
||||
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
|
||||
{
|
||||
_adjustedForScollBarsLocalProfile = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_adjustedForScollBarsLocalProfile = false;
|
||||
}
|
||||
childFrameLocal = childFrameLocal with
|
||||
{
|
||||
X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0),
|
||||
};
|
||||
if (ImGui.BeginChildFrame(102, childFrameLocal))
|
||||
{
|
||||
UiSharedService.TextWrapped(_descriptionText);
|
||||
}
|
||||
ImGui.EndChildFrame();
|
||||
}
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Sets your profile description text");
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
UiSharedService.AttachToolTip("Clears your profile description text");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Profile Options:");
|
||||
var isNsfw = _profileData.IsNsfw;
|
||||
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
|
||||
{
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: isNsfw, IsDisabled: null));
|
||||
}
|
||||
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
|
||||
ImGui.TreePop();
|
||||
}
|
||||
}
|
||||
profileTab.Dispose();
|
||||
}
|
||||
|
||||
private void DrawManagement()
|
||||
{
|
||||
@@ -192,7 +406,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp;
|
||||
if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY;
|
||||
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.GID, 3, tableFlags);
|
||||
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.AliasOrGID, 3, tableFlags);
|
||||
if (table)
|
||||
{
|
||||
ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4);
|
||||
@@ -336,7 +550,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
ImGui.Separator();
|
||||
|
||||
if (_uiSharedService.MediumTreeNode("Mass Cleanup", UIColors.Get("LightlessPurple")))
|
||||
if (_uiSharedService.MediumTreeNode("Mass Cleanup", UIColors.Get("DimRed")))
|
||||
{
|
||||
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
|
||||
{
|
||||
@@ -348,6 +562,18 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
UiSharedService.AttachToolTip("This will remove all non-pinned, non-moderator users from the Syncshell."
|
||||
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
|
||||
{
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Brush, "Clear Lightfinder Users"))
|
||||
{
|
||||
_ = _apiController.GroupClearFinder(new(GroupFullInfo.Group));
|
||||
}
|
||||
}
|
||||
UiSharedService.AttachToolTip("This will remove all users that joined through Lightfinder from the Syncshell."
|
||||
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
|
||||
|
||||
ImGuiHelpers.ScaledDummy(2f);
|
||||
ImGui.Separator();
|
||||
ImGuiHelpers.ScaledDummy(2f);
|
||||
@@ -410,12 +636,12 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
UiSharedService.TextWrapped($"Syncshell was pruned and {_pruneTask.Result} inactive user(s) have been removed.");
|
||||
}
|
||||
}
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
ImGui.Separator();
|
||||
|
||||
if (_uiSharedService.MediumTreeNode("User Bans", UIColors.Get("LightlessPurple")))
|
||||
if (_uiSharedService.MediumTreeNode("User Bans", UIColors.Get("LightlessYellow")))
|
||||
{
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server"))
|
||||
{
|
||||
@@ -456,13 +682,12 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
ImGui.EndTable();
|
||||
}
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
ImGui.Separator();
|
||||
}
|
||||
mgmtTab.Dispose();
|
||||
|
||||
}
|
||||
|
||||
private void DrawInvites(GroupPermissions perm)
|
||||
@@ -509,9 +734,37 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
inviteTab.Dispose();
|
||||
}
|
||||
private void DrawTag(int tag)
|
||||
{
|
||||
var HasTag = _selectedTags.Contains(tag);
|
||||
var tagName = (ProfileTags)tag;
|
||||
|
||||
if (ImGui.Checkbox(tagName.ToString(), ref HasTag))
|
||||
{
|
||||
if (HasTag)
|
||||
{
|
||||
_selectedTags.Add(tag);
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedTags.Remove(tag);
|
||||
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GetTagsFromProfile()
|
||||
{
|
||||
if (_profileData != null)
|
||||
{
|
||||
_selectedTags = [.. _profileData.Tags];
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
Mediator.Publish(new RemoveWindowMessage(this));
|
||||
_pfpTextureWrap?.Dispose();
|
||||
}
|
||||
}
|
||||
336
LightlessSync/UI/SyncshellFinderUI.cs
Normal file
336
LightlessSync/UI/SyncshellFinderUI.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
public class SyncshellFinderUI : WindowMediatorSubscriberBase
|
||||
{
|
||||
private readonly ApiController _apiController;
|
||||
private readonly BroadcastService _broadcastService;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly BroadcastScannerService _broadcastScannerService;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
|
||||
private readonly List<GroupJoinDto> _nearbySyncshells = [];
|
||||
private List<GroupFullInfoDto> _currentSyncshells = [];
|
||||
private int _selectedNearbyIndex = -1;
|
||||
private readonly HashSet<string> _recentlyJoined = new(StringComparer.Ordinal);
|
||||
|
||||
private GroupJoinDto? _joinDto;
|
||||
private GroupJoinInfoDto? _joinInfo;
|
||||
private DefaultPermissionsDto _ownPermissions = null!;
|
||||
|
||||
public SyncshellFinderUI(
|
||||
ILogger<SyncshellFinderUI> logger,
|
||||
LightlessMediator mediator,
|
||||
PerformanceCollectorService performanceCollectorService,
|
||||
BroadcastService broadcastService,
|
||||
UiSharedService uiShared,
|
||||
ApiController apiController,
|
||||
BroadcastScannerService broadcastScannerService,
|
||||
PairManager pairManager,
|
||||
DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService)
|
||||
{
|
||||
_broadcastService = broadcastService;
|
||||
_uiSharedService = uiShared;
|
||||
_apiController = apiController;
|
||||
_broadcastScannerService = broadcastScannerService;
|
||||
_pairManager = pairManager;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
|
||||
IsOpen = false;
|
||||
SizeConstraints = new()
|
||||
{
|
||||
MinimumSize = new(600, 400),
|
||||
MaximumSize = new(600, 550)
|
||||
};
|
||||
|
||||
Mediator.Subscribe<SyncshellBroadcastsUpdatedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
|
||||
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
|
||||
}
|
||||
|
||||
public override async void OnOpen()
|
||||
{
|
||||
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
|
||||
await RefreshSyncshellsAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
_uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("PairBlue"));
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("PairBlue"));
|
||||
|
||||
if (_nearbySyncshells.Count == 0)
|
||||
{
|
||||
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
|
||||
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
{
|
||||
|
||||
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"));
|
||||
|
||||
ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active.");
|
||||
ImGuiHelpers.ScaledDummy(0.5f);
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue"));
|
||||
|
||||
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
|
||||
{
|
||||
Mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
|
||||
}
|
||||
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.PopStyleVar();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DrawSyncshellTable();
|
||||
|
||||
if (_joinDto != null && _joinInfo != null && _joinInfo.Success)
|
||||
DrawConfirmation();
|
||||
}
|
||||
|
||||
private void DrawSyncshellTable()
|
||||
{
|
||||
if (ImGui.BeginTable("##NearbySyncshellsTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg))
|
||||
{
|
||||
ImGui.TableSetupColumn("Syncshell", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("Broadcaster", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("Join", ImGuiTableColumnFlags.WidthFixed, 80f * ImGuiHelpers.GlobalScale);
|
||||
ImGui.TableHeadersRow();
|
||||
|
||||
foreach (var shell in _nearbySyncshells)
|
||||
{
|
||||
// Check if there is an active broadcast for this syncshell, if not, skipping this syncshell
|
||||
var broadcast = _broadcastScannerService.GetActiveSyncshellBroadcasts()
|
||||
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
|
||||
|
||||
if (broadcast == null)
|
||||
continue; // no active broadcasts
|
||||
|
||||
var (Name, Address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
|
||||
if (string.IsNullOrEmpty(Name))
|
||||
continue; // broadcaster not found in area, skipping
|
||||
|
||||
ImGui.TableNextRow();
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID;
|
||||
ImGui.TextUnformatted(displayName);
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(Address);
|
||||
var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{Name} ({worldName})" : Name;
|
||||
ImGui.TextUnformatted(broadcasterName);
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
var label = $"Join##{shell.Group.GID}";
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f));
|
||||
|
||||
var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal));
|
||||
var isRecentlyJoined = _recentlyJoined.Contains(shell.GID);
|
||||
|
||||
if (!isAlreadyMember && !isRecentlyJoined)
|
||||
{
|
||||
if (ImGui.Button(label))
|
||||
{
|
||||
_logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto(
|
||||
shell.Group,
|
||||
shell.Password,
|
||||
shell.GroupUserPreferredPermissions
|
||||
)).ConfigureAwait(false);
|
||||
|
||||
if (info != null && info.Success)
|
||||
{
|
||||
_joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions);
|
||||
_joinInfo = info;
|
||||
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
|
||||
|
||||
_logger.LogInformation($"Fetched join info for {shell.Group.GID}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Join failed for {shell.Group.GID}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using (ImRaii.Disabled())
|
||||
{
|
||||
ImGui.Button(label);
|
||||
}
|
||||
UiSharedService.AttachToolTip("Already a member or owner of this Syncshell.");
|
||||
}
|
||||
ImGui.PopStyleColor(3);
|
||||
}
|
||||
|
||||
ImGui.EndTable();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawConfirmation()
|
||||
{
|
||||
if (_joinDto != null && _joinInfo != null)
|
||||
{
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}");
|
||||
ImGuiHelpers.ScaledDummy(2f);
|
||||
ImGui.TextUnformatted("Suggested Syncshell Permissions:");
|
||||
|
||||
DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v);
|
||||
DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v);
|
||||
DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v);
|
||||
|
||||
ImGui.NewLine();
|
||||
ImGui.NewLine();
|
||||
|
||||
if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}"))
|
||||
{
|
||||
var finalPermissions = GroupUserPreferredPermissions.NoneSet;
|
||||
finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds);
|
||||
finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations);
|
||||
finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX);
|
||||
|
||||
_ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions));
|
||||
|
||||
_recentlyJoined.Add(_joinDto.Group.GID);
|
||||
|
||||
_joinDto = null;
|
||||
_joinInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPermissionRow(string label, bool suggested, bool current, Action<bool> apply)
|
||||
{
|
||||
ImGui.AlignTextToFramePadding();
|
||||
ImGui.TextUnformatted($"- {label}");
|
||||
|
||||
ImGui.SameLine(150 * ImGuiHelpers.GlobalScale);
|
||||
ImGui.TextUnformatted("Current:");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.BooleanToColoredIcon(!current);
|
||||
|
||||
ImGui.SameLine(300 * ImGuiHelpers.GlobalScale);
|
||||
ImGui.TextUnformatted("Suggested:");
|
||||
ImGui.SameLine();
|
||||
_uiSharedService.BooleanToColoredIcon(!suggested);
|
||||
|
||||
ImGui.SameLine(450 * ImGuiHelpers.GlobalScale);
|
||||
using var id = ImRaii.PushId(label);
|
||||
if (current != suggested)
|
||||
{
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply"))
|
||||
apply(suggested);
|
||||
}
|
||||
|
||||
ImGui.NewLine();
|
||||
}
|
||||
|
||||
private async Task RefreshSyncshellsAsync()
|
||||
{
|
||||
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
|
||||
_currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)];
|
||||
|
||||
_recentlyJoined.RemoveWhere(gid => _currentSyncshells.Any(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));
|
||||
|
||||
if (syncshellBroadcasts.Count == 0)
|
||||
{
|
||||
ClearSyncshells();
|
||||
return;
|
||||
}
|
||||
|
||||
List<GroupJoinDto>? updatedList = [];
|
||||
try
|
||||
{
|
||||
var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts).ConfigureAwait(false);
|
||||
updatedList = groups?.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh broadcasted syncshells.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedList != null)
|
||||
{
|
||||
var previousGid = GetSelectedGid();
|
||||
|
||||
_nearbySyncshells.Clear();
|
||||
_nearbySyncshells.AddRange(updatedList);
|
||||
|
||||
if (previousGid != null)
|
||||
{
|
||||
var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
|
||||
if (newIndex >= 0)
|
||||
{
|
||||
_selectedNearbyIndex = newIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
private void ClearSyncshells()
|
||||
{
|
||||
if (_nearbySyncshells.Count == 0)
|
||||
return;
|
||||
|
||||
_nearbySyncshells.Clear();
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
_selectedNearbyIndex = -1;
|
||||
_joinDto = null;
|
||||
_joinInfo = null;
|
||||
}
|
||||
|
||||
private string? GetSelectedGid()
|
||||
{
|
||||
if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
|
||||
return null;
|
||||
|
||||
return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using System.Numerics;
|
||||
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
public class TopTabMenu
|
||||
@@ -19,19 +23,28 @@ public class TopTabMenu
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly HashSet<string> _pendingPairRequestActions = new(StringComparer.Ordinal);
|
||||
private bool _pairRequestsExpanded; // useless for now
|
||||
private int _lastRequestCount;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly NotificationService _lightlessNotificationService;
|
||||
private string _filter = string.Empty;
|
||||
private int _globalControlCountdown = 0;
|
||||
|
||||
private float _pairRequestsHeight = 150f;
|
||||
private string _pairToAdd = string.Empty;
|
||||
|
||||
private SelectedTab _selectedTab = SelectedTab.None;
|
||||
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService)
|
||||
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService)
|
||||
{
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_apiController = apiController;
|
||||
_pairManager = pairManager;
|
||||
_pairRequestService = pairRequestService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_uiSharedService = uiSharedService;
|
||||
_lightlessNotificationService = lightlessNotificationService;
|
||||
}
|
||||
|
||||
private enum SelectedTab
|
||||
@@ -39,8 +52,9 @@ public class TopTabMenu
|
||||
None,
|
||||
Individual,
|
||||
Syncshell,
|
||||
Filter,
|
||||
UserConfig
|
||||
Lightfinder,
|
||||
UserConfig,
|
||||
Settings
|
||||
}
|
||||
|
||||
public string Filter
|
||||
@@ -60,11 +74,6 @@ public class TopTabMenu
|
||||
{
|
||||
get => _selectedTab; set
|
||||
{
|
||||
if (_selectedTab == SelectedTab.Filter && value != SelectedTab.Filter)
|
||||
{
|
||||
Filter = string.Empty;
|
||||
}
|
||||
|
||||
_selectedTab = value;
|
||||
}
|
||||
}
|
||||
@@ -72,11 +81,11 @@ public class TopTabMenu
|
||||
{
|
||||
var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
|
||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
||||
var buttonX = (availableWidth - (spacing.X * 3)) / 4f;
|
||||
var buttonX = (availableWidth - (spacing.X * 4)) / 5f;
|
||||
var buttonY = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Pause).Y;
|
||||
var buttonSize = new Vector2(buttonX, buttonY);
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var underlineColor = ImGui.GetColorU32(ImGuiCol.Separator);
|
||||
var underlineColor = ImGui.GetColorU32(UIColors.Get("LightlessPurpleActive")); // ImGui.GetColorU32(ImGuiCol.Separator);
|
||||
var btncolor = ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)));
|
||||
|
||||
ImGuiHelpers.ScaledDummy(spacing.Y / 2f);
|
||||
@@ -117,19 +126,19 @@ public class TopTabMenu
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
var x = ImGui.GetCursorScreenPos();
|
||||
if (ImGui.Button(FontAwesomeIcon.Filter.ToIconString(), buttonSize))
|
||||
if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize))
|
||||
{
|
||||
TabSelection = TabSelection == SelectedTab.Filter ? SelectedTab.None : SelectedTab.Filter;
|
||||
TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
var xAfter = ImGui.GetCursorScreenPos();
|
||||
if (TabSelection == SelectedTab.Filter)
|
||||
if (TabSelection == SelectedTab.Lightfinder)
|
||||
drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y },
|
||||
xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X },
|
||||
underlineColor, 2);
|
||||
}
|
||||
UiSharedService.AttachToolTip("Filter");
|
||||
UiSharedService.AttachToolTip("Lightfinder");
|
||||
|
||||
ImGui.SameLine();
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
@@ -149,6 +158,18 @@ public class TopTabMenu
|
||||
}
|
||||
UiSharedService.AttachToolTip("Your User Menu");
|
||||
|
||||
ImGui.SameLine();
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
var x = ImGui.GetCursorScreenPos();
|
||||
if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString(), buttonSize))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
|
||||
}
|
||||
ImGui.SameLine();
|
||||
}
|
||||
UiSharedService.AttachToolTip("Open Lightless Settings");
|
||||
|
||||
ImGui.NewLine();
|
||||
btncolor.Dispose();
|
||||
|
||||
@@ -164,9 +185,9 @@ public class TopTabMenu
|
||||
DrawSyncshellMenu(availableWidth, spacing.X);
|
||||
DrawGlobalSyncshellButtons(availableWidth, spacing.X);
|
||||
}
|
||||
else if (TabSelection == SelectedTab.Filter)
|
||||
else if (TabSelection == SelectedTab.Lightfinder)
|
||||
{
|
||||
DrawFilter(availableWidth, spacing.X);
|
||||
DrawLightfinderMenu(availableWidth, spacing.X);
|
||||
}
|
||||
else if (TabSelection == SelectedTab.UserConfig)
|
||||
{
|
||||
@@ -174,7 +195,12 @@ public class TopTabMenu
|
||||
}
|
||||
|
||||
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
|
||||
|
||||
DrawIncomingPairRequests(availableWidth);
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
DrawFilter(availableWidth, spacing.X);
|
||||
}
|
||||
|
||||
private void DrawAddPair(float availableXWidth, float spacingX)
|
||||
@@ -195,6 +221,207 @@ public class TopTabMenu
|
||||
UiSharedService.AttachToolTip("Pair with " + (_pairToAdd.IsNullOrEmpty() ? "other user" : _pairToAdd));
|
||||
}
|
||||
|
||||
private void DrawIncomingPairRequests(float availableWidth)
|
||||
{
|
||||
var requests = _pairRequestService.GetActiveRequests();
|
||||
var count = requests.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
_pairRequestsExpanded = false;
|
||||
_lastRequestCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (count > _lastRequestCount)
|
||||
{
|
||||
_pairRequestsExpanded = true;
|
||||
}
|
||||
_lastRequestCount = count;
|
||||
|
||||
var label = $"Incoming Pair Requests - {count}##IncomingPairRequestsHeader";
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushColor(ImGuiCol.HeaderHovered, UIColors.Get("LightlessPurpleActive")))
|
||||
using (ImRaii.PushColor(ImGuiCol.HeaderActive, UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
bool open = ImGui.CollapsingHeader(label, ImGuiTreeNodeFlags.DefaultOpen);
|
||||
_pairRequestsExpanded = open;
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
UiSharedService.AttachToolTip("Expand to view incoming pair requests.");
|
||||
|
||||
if (open)
|
||||
{
|
||||
var lineHeight = ImGui.GetTextLineHeightWithSpacing();
|
||||
//var desiredHeight = Math.Clamp(count * lineHeight * 2f, 130f * ImGuiHelpers.GlobalScale, 185f * ImGuiHelpers.GlobalScale); we use resize bar instead
|
||||
|
||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() - 2f);
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.ChildBg, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
|
||||
{
|
||||
if (ImGui.BeginChild("##IncomingPairRequestsOuter", new Vector2(availableWidth + 5f, _pairRequestsHeight), true))
|
||||
{
|
||||
var defaultChildBg = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg];
|
||||
using (ImRaii.PushColor(ImGuiCol.ChildBg, defaultChildBg))
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f))
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(6, 6)))
|
||||
{
|
||||
if (ImGui.BeginChild("##IncomingPairRequestsInner", new Vector2(0, 0), true))
|
||||
{
|
||||
using (ImRaii.PushColor(ImGuiCol.TableBorderStrong, ImGui.GetStyle().Colors[(int)ImGuiCol.Border]))
|
||||
using (ImRaii.PushColor(ImGuiCol.TableBorderLight, ImGui.GetStyle().Colors[(int)ImGuiCol.Border]))
|
||||
{
|
||||
DrawPairRequestList(requests);
|
||||
}
|
||||
}
|
||||
ImGui.EndChild();
|
||||
}
|
||||
}
|
||||
ImGui.EndChild();
|
||||
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("ButtonDefault"));
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple"));
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive"));
|
||||
ImGui.Button("##resizeHandle", new Vector2(availableWidth, 4f));
|
||||
ImGui.PopStyleColor(3);
|
||||
|
||||
if (ImGui.IsItemActive())
|
||||
{
|
||||
_pairRequestsHeight += ImGui.GetIO().MouseDelta.Y;
|
||||
_pairRequestsHeight = Math.Clamp(_pairRequestsHeight, 100f, 300f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPairRequestList(IReadOnlyList<PairRequestService.PairRequestDisplay> requests)
|
||||
{
|
||||
float playerColWidth = 207f * ImGuiHelpers.GlobalScale;
|
||||
float receivedColWidth = 73f * ImGuiHelpers.GlobalScale;
|
||||
float actionsColWidth = 50f * ImGuiHelpers.GlobalScale;
|
||||
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted("Player");
|
||||
ImGui.SameLine(playerColWidth + 2f);
|
||||
ImGui.TextUnformatted("Received");
|
||||
ImGui.SameLine(playerColWidth + receivedColWidth + 12f);
|
||||
ImGui.TextUnformatted("Actions");
|
||||
ImGui.Separator();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
ImGui.BeginGroup();
|
||||
|
||||
var label = string.IsNullOrEmpty(request.DisplayName) ? request.HashedCid : request.DisplayName;
|
||||
|
||||
ImGui.TextUnformatted(label.Truncate(26));
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.TextUnformatted(label);
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
ImGui.SameLine(playerColWidth);
|
||||
|
||||
ImGui.TextUnformatted(GetRelativeTime(request.ReceivedAt));
|
||||
ImGui.SameLine(playerColWidth + receivedColWidth);
|
||||
|
||||
DrawPairRequestActions(request);
|
||||
|
||||
ImGui.EndGroup();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPairRequestActions(PairRequestService.PairRequestDisplay request)
|
||||
{
|
||||
using var id = ImRaii.PushId(request.HashedCid);
|
||||
var label = string.IsNullOrEmpty(request.DisplayName) ? request.HashedCid : request.DisplayName;
|
||||
var inFlight = _pendingPairRequestActions.Contains(request.HashedCid);
|
||||
using (ImRaii.Disabled(inFlight))
|
||||
{
|
||||
if (_uiSharedService.IconButton(FontAwesomeIcon.Check))
|
||||
{
|
||||
_ = AcceptPairRequestAsync(request);
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Accept request");
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (_uiSharedService.IconButton(FontAwesomeIcon.Times))
|
||||
{
|
||||
RejectPairRequest(request.HashedCid, label);
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Decline request");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRelativeTime(DateTime receivedAt)
|
||||
{
|
||||
var delta = DateTime.UtcNow - receivedAt;
|
||||
if (delta <= TimeSpan.FromSeconds(10))
|
||||
{
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
if (delta.TotalMinutes >= 1)
|
||||
{
|
||||
return $"{Math.Floor(delta.TotalMinutes)}m {delta.Seconds:D2}s ago";
|
||||
}
|
||||
|
||||
return $"{delta.Seconds}s ago";
|
||||
}
|
||||
|
||||
private async Task AcceptPairRequestAsync(PairRequestService.PairRequestDisplay request)
|
||||
{
|
||||
if (!_pendingPairRequestActions.Add(request.HashedCid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var myCidHash = (await _dalamudUtilService.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
|
||||
await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false);
|
||||
_pairRequestService.RemoveRequest(request.HashedCid);
|
||||
|
||||
var display = string.IsNullOrEmpty(request.DisplayName) ? request.HashedCid : request.DisplayName;
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Pair request accepted",
|
||||
$"Sent a pair request back to {display}.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Failed to accept pair request",
|
||||
ex.Message,
|
||||
NotificationType.Error,
|
||||
TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingPairRequestActions.Remove(request.HashedCid);
|
||||
}
|
||||
}
|
||||
|
||||
private void RejectPairRequest(string hashedCid, string playerName)
|
||||
{
|
||||
if (!_pairRequestService.RemoveRequest(hashedCid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lightlessMediator.Publish(new NotificationMessage("Pair request declined", "Declined " + playerName + "'s pending pair request.",
|
||||
NotificationType.Info,
|
||||
TimeSpan.FromSeconds(3)));
|
||||
}
|
||||
|
||||
private void DrawFilter(float availableWidth, float spacingX)
|
||||
{
|
||||
var buttonSize = _uiSharedService.GetIconTextButtonSize(FontAwesomeIcon.Ban, "Clear");
|
||||
@@ -483,6 +710,23 @@ public class TopTabMenu
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLightfinderMenu(float availableWidth, float spacingX)
|
||||
{
|
||||
var buttonX = (availableWidth - (spacingX)) / 2f;
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, "Lightfinder", buttonX, center: true))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, "Syncshell Finder", buttonX, center: true))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawUserConfig(float availableWidth, float spacingX)
|
||||
{
|
||||
var buttonX = (availableWidth - spacingX) / 2f;
|
||||
@@ -591,4 +835,4 @@ public class TopTabMenu
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,22 @@ namespace LightlessSync.UI
|
||||
private static readonly Dictionary<string, string> DefaultHexColors = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "LightlessPurple", "#ad8af5" },
|
||||
{ "LightlessPurpleActive", "#be9eff" },
|
||||
{ "LightlessPurpleDefault", "#9375d1" },
|
||||
{ "ButtonDefault", "#323232" },
|
||||
{ "FullBlack", "#000000" },
|
||||
{ "LightlessBlue", "#a6c2ff" },
|
||||
{ "LightlessYellow", "#ffe97a" },
|
||||
{ "LightlessGreen", "#7cd68a" },
|
||||
{ "LightlessOrange", "#ffb366" },
|
||||
{ "PairBlue", "#88a2db" },
|
||||
{ "DimRed", "#d44444" },
|
||||
{ "LightlessAdminText", "#ffd663" },
|
||||
{ "LightlessAdminGlow", "#b09343" },
|
||||
{ "LightlessModeratorText", "#94ffda" },
|
||||
|
||||
{ "Lightfinder", "#ad8af5" },
|
||||
{ "LightfinderEdge", "#000000" },
|
||||
};
|
||||
|
||||
private static LightlessConfigService? _configService;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.GameFonts;
|
||||
@@ -7,6 +7,7 @@ using Dalamud.Interface.ManagedFontAtlas;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using System;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
@@ -173,12 +174,14 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
|
||||
public static string ByteToString(long bytes, bool addSuffix = true)
|
||||
{
|
||||
string[] suffix = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
int i;
|
||||
string[] suffix = { "B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
||||
int i = 0;
|
||||
double dblSByte = bytes;
|
||||
for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024)
|
||||
|
||||
while (dblSByte >= 1000 && i < suffix.Length - 1)
|
||||
{
|
||||
dblSByte = bytes / 1024.0;
|
||||
dblSByte /= 1000.0;
|
||||
i++;
|
||||
}
|
||||
|
||||
return addSuffix ? $"{dblSByte:0.00} {suffix[i]}" : $"{dblSByte:0.00}";
|
||||
@@ -491,10 +494,90 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
ImGui.Dummy(new Vector2(0, thickness * ImGuiHelpers.GlobalScale));
|
||||
}
|
||||
|
||||
public void RoundedSeparator(Vector4? color = null, float thickness = 2f, float indent = 0f, float rounding = 4f)
|
||||
{
|
||||
float scale = ImGuiHelpers.GlobalScale;
|
||||
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var min = ImGui.GetCursorScreenPos();
|
||||
var contentWidth = ImGui.GetContentRegionAvail().X;
|
||||
|
||||
min.X += indent * scale;
|
||||
var max = new Vector2(min.X + (contentWidth - indent * 2f) * scale, min.Y + thickness * scale);
|
||||
|
||||
var col = ImGui.GetColorU32(color ?? ImGuiColors.DalamudGrey);
|
||||
|
||||
|
||||
drawList.AddRectFilled(min, max, col, rounding);
|
||||
|
||||
ImGui.Dummy(new Vector2(0, thickness * scale));
|
||||
}
|
||||
|
||||
public static bool CheckboxWithBorder(string label, ref bool value, Vector4? borderColor = null, float borderThickness = 1.0f, float rounding = 3.0f)
|
||||
{
|
||||
var pos = ImGui.GetCursorScreenPos();
|
||||
|
||||
bool changed = ImGui.Checkbox(label, ref value);
|
||||
|
||||
var min = pos;
|
||||
var max = ImGui.GetItemRectMax();
|
||||
|
||||
var col = ImGui.GetColorU32(borderColor ?? ImGuiColors.DalamudGrey);
|
||||
ImGui.GetWindowDrawList().AddRect(min, max, col, rounding, ImDrawFlags.None, borderThickness);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public void MediumText(string text, Vector4? color = null)
|
||||
{
|
||||
FontText(text, MediumFont, color);
|
||||
}
|
||||
public void DrawNoteLine(string icon, Vector4 color, string text)
|
||||
{
|
||||
MediumText(icon, color);
|
||||
var iconHeight = ImGui.GetItemRectSize().Y;
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
float textHeight = ImGui.GetTextLineHeight();
|
||||
float offset = (iconHeight - textHeight) * 0.5f;
|
||||
if (offset > 0)
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + offset);
|
||||
|
||||
ImGui.BeginGroup();
|
||||
ImGui.TextWrapped(text);
|
||||
ImGui.EndGroup();
|
||||
}
|
||||
|
||||
public void DrawNoteLine(string icon, Vector4 color, ReadOnlySpan<SeStringUtils.RichTextEntry> fragments)
|
||||
{
|
||||
if (fragments.Length == 0)
|
||||
{
|
||||
DrawNoteLine(icon, color, string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
MediumText(icon, color);
|
||||
var iconHeight = ImGui.GetItemRectSize().Y;
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
float textHeight = ImGui.GetTextLineHeight();
|
||||
float offset = (iconHeight - textHeight) * 0.5f;
|
||||
if (offset > 0)
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + offset);
|
||||
|
||||
var wrapWidth = ImGui.GetContentRegionAvail().X;
|
||||
ImGui.BeginGroup();
|
||||
var richText = SeStringUtils.BuildRichText(fragments);
|
||||
SeStringUtils.RenderSeStringWrapped(richText, wrapWidth);
|
||||
ImGui.EndGroup();
|
||||
}
|
||||
|
||||
public void DrawNoteLine(string icon, Vector4 color, params SeStringUtils.RichTextEntry[] fragments)
|
||||
{
|
||||
DrawNoteLine(icon, color, fragments.AsSpan());
|
||||
}
|
||||
|
||||
public bool MediumTreeNode(string label, Vector4? textColor = null, float lineWidth = 2f, ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.SpanAvailWidth)
|
||||
{
|
||||
@@ -1125,11 +1208,11 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
IconText(icon, color == null ? ImGui.GetColorU32(ImGuiCol.Text) : ImGui.GetColorU32(color.Value));
|
||||
}
|
||||
|
||||
public bool IconTextButton(FontAwesomeIcon icon, string text, float? width = null, bool isInPopup = false)
|
||||
public bool IconTextButton(FontAwesomeIcon icon, string text, float? width = null, bool isInPopup = false, bool? center = null)
|
||||
{
|
||||
return IconTextButtonInternal(icon, text,
|
||||
isInPopup ? ColorHelpers.RgbaUintToVector4(ImGui.GetColorU32(ImGuiCol.PopupBg)) : null,
|
||||
width <= 0 ? null : width);
|
||||
width <= 0 ? null : width, center);
|
||||
}
|
||||
|
||||
public IDalamudTextureWrap LoadImage(byte[] imageData)
|
||||
@@ -1193,7 +1276,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
ImGui.TextUnformatted(text);
|
||||
}
|
||||
|
||||
private bool IconTextButtonInternal(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, float? width = null)
|
||||
private bool IconTextButtonInternal(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, float? width = null, bool? center = null)
|
||||
{
|
||||
int num = 0;
|
||||
if (defaultColor.HasValue)
|
||||
@@ -1203,19 +1286,35 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
ImGui.PushID(text);
|
||||
|
||||
Vector2 vector;
|
||||
using (IconFont.Push())
|
||||
vector = ImGui.CalcTextSize(icon.ToIconString());
|
||||
|
||||
Vector2 vector2 = ImGui.CalcTextSize(text);
|
||||
ImDrawListPtr windowDrawList = ImGui.GetWindowDrawList();
|
||||
Vector2 cursorScreenPos = ImGui.GetCursorScreenPos();
|
||||
float num2 = 3f * ImGuiHelpers.GlobalScale;
|
||||
float x = width ?? vector.X + vector2.X + ImGui.GetStyle().FramePadding.X * 2f + num2;
|
||||
|
||||
float totalTextWidth = vector.X + num2 + vector2.X;
|
||||
float x = width ?? totalTextWidth + ImGui.GetStyle().FramePadding.X * 2f;
|
||||
float frameHeight = ImGui.GetFrameHeight();
|
||||
|
||||
bool result = ImGui.Button(string.Empty, new Vector2(x, frameHeight));
|
||||
Vector2 pos = new Vector2(cursorScreenPos.X + ImGui.GetStyle().FramePadding.X, cursorScreenPos.Y + ImGui.GetStyle().FramePadding.Y);
|
||||
|
||||
bool shouldCenter = center == true;
|
||||
|
||||
Vector2 pos = shouldCenter
|
||||
? new Vector2(
|
||||
cursorScreenPos.X + (x - totalTextWidth) / 2f,
|
||||
cursorScreenPos.Y + (frameHeight - vector.Y) / 2f)
|
||||
: new Vector2(
|
||||
cursorScreenPos.X + ImGui.GetStyle().FramePadding.X,
|
||||
cursorScreenPos.Y + ImGui.GetStyle().FramePadding.Y);
|
||||
|
||||
using (IconFont.Push())
|
||||
windowDrawList.AddText(pos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString());
|
||||
|
||||
Vector2 pos2 = new Vector2(pos.X + vector.X + num2, cursorScreenPos.Y + ImGui.GetStyle().FramePadding.Y);
|
||||
windowDrawList.AddText(pos2, ImGui.GetColorU32(ImGuiCol.Text), text);
|
||||
ImGui.PopID();
|
||||
|
||||
767
LightlessSync/UI/UpdateNotesUi.cs
Normal file
767
LightlessSync/UI/UpdateNotesUi.cs
Normal file
@@ -0,0 +1,767 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.UI.Models;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
// Inspiration taken from Brio and Character Select+ (goats)
|
||||
public class UpdateNotesUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
private readonly UiSharedService _uiShared;
|
||||
private readonly LightlessConfigService _configService;
|
||||
|
||||
private ChangelogFile _changelog = new();
|
||||
private CreditsFile _credits = new();
|
||||
private bool _scrollToTop;
|
||||
private int _selectedTab;
|
||||
private bool _hasInitializedCollapsingHeaders;
|
||||
|
||||
private struct Particle
|
||||
{
|
||||
public Vector2 Position;
|
||||
public Vector2 Velocity;
|
||||
public float Life;
|
||||
public float MaxLife;
|
||||
public float Size;
|
||||
public ParticleType Type;
|
||||
public List<Vector2>? Trail;
|
||||
public float Twinkle;
|
||||
public float Depth;
|
||||
public float Hue;
|
||||
}
|
||||
|
||||
private enum ParticleType
|
||||
{
|
||||
TwinklingStar,
|
||||
ShootingStar
|
||||
}
|
||||
|
||||
private readonly List<Particle> _particles = [];
|
||||
private float _particleSpawnTimer;
|
||||
private readonly Random _random = new();
|
||||
|
||||
private const float _headerHeight = 150f;
|
||||
private const float _particleSpawnInterval = 0.2f;
|
||||
private const int _maxParticles = 50;
|
||||
private const int _maxTrailLength = 50;
|
||||
private const float _edgeFadeDistance = 30f;
|
||||
private const float _extendedParticleHeight = 40f;
|
||||
|
||||
public UpdateNotesUi(ILogger<UpdateNotesUi> logger,
|
||||
LightlessMediator mediator,
|
||||
UiSharedService uiShared,
|
||||
LightlessConfigService configService,
|
||||
PerformanceCollectorService performanceCollectorService)
|
||||
: base(logger, mediator, "Lightless Sync — Update Notes", performanceCollectorService)
|
||||
{
|
||||
logger.LogInformation("UpdateNotesUi constructor called");
|
||||
_uiShared = uiShared;
|
||||
_configService = configService;
|
||||
|
||||
AllowClickthrough = false;
|
||||
AllowPinning = false;
|
||||
RespectCloseHotkey = true;
|
||||
ShowCloseButton = true;
|
||||
|
||||
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse |
|
||||
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
|
||||
|
||||
SizeConstraints = new WindowSizeConstraints()
|
||||
{
|
||||
MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700),
|
||||
};
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
LoadEmbeddedResources();
|
||||
logger.LogInformation("UpdateNotesUi constructor completed successfully");
|
||||
}
|
||||
|
||||
public override void OnOpen()
|
||||
{
|
||||
_scrollToTop = true;
|
||||
_hasInitializedCollapsingHeaders = false;
|
||||
}
|
||||
|
||||
private void CenterWindow()
|
||||
{
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
var center = viewport.GetCenter();
|
||||
var windowSize = new Vector2(800f * ImGuiHelpers.GlobalScale, 700f * ImGuiHelpers.GlobalScale);
|
||||
Position = center - windowSize / 2f;
|
||||
}
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
if (_uiShared.IsInGpose)
|
||||
return;
|
||||
|
||||
CenterWindow();
|
||||
DrawHeader();
|
||||
ImGuiHelpers.ScaledDummy(6);
|
||||
DrawTabs();
|
||||
DrawCloseButton();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
var windowPos = ImGui.GetWindowPos();
|
||||
var windowPadding = ImGui.GetStyle().WindowPadding;
|
||||
var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2);
|
||||
|
||||
var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y);
|
||||
var headerEnd = headerStart + new Vector2(headerWidth, _headerHeight);
|
||||
|
||||
var extendedParticleSize = new Vector2(headerWidth, _headerHeight + _extendedParticleHeight);
|
||||
|
||||
DrawGradientBackground(headerStart, headerEnd);
|
||||
DrawHeaderText(headerStart);
|
||||
DrawHeaderButtons(headerStart, headerWidth);
|
||||
DrawBottomGradient(headerStart, headerEnd, headerWidth);
|
||||
|
||||
ImGui.SetCursorPosY(windowPadding.Y + _headerHeight + 5);
|
||||
ImGui.SetCursorPosX(20);
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
ImGui.TextColored(UIColors.Get("LightlessGreen"), FontAwesomeIcon.Star.ToIconString());
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.TextColored(UIColors.Get("LightlessGreen"), "What's New");
|
||||
|
||||
if (!string.IsNullOrEmpty(_changelog.Tagline))
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10);
|
||||
ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), _changelog.Tagline);
|
||||
|
||||
if (!string.IsNullOrEmpty(_changelog.Subline))
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.75f, 1.0f), $" – {_changelog.Subline}");
|
||||
}
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
|
||||
DrawParticleEffects(headerStart, extendedParticleSize);
|
||||
}
|
||||
|
||||
private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f);
|
||||
var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f);
|
||||
|
||||
drawList.AddRectFilledMultiColor(
|
||||
headerStart,
|
||||
headerEnd,
|
||||
ImGui.GetColorU32(darkPurple),
|
||||
ImGui.GetColorU32(darkPurple),
|
||||
ImGui.GetColorU32(deepPurple),
|
||||
ImGui.GetColorU32(deepPurple)
|
||||
);
|
||||
|
||||
var random = new Random(42);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var starPos = headerStart + new Vector2(
|
||||
(float)random.NextDouble() * (headerEnd.X - headerStart.X),
|
||||
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
|
||||
);
|
||||
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
|
||||
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var gradientHeight = 60f;
|
||||
|
||||
for (int i = 0; i < gradientHeight; i++)
|
||||
{
|
||||
var progress = i / gradientHeight;
|
||||
var smoothProgress = progress * progress;
|
||||
var r = 0.12f + (0.0f - 0.12f) * smoothProgress;
|
||||
var g = 0.08f + (0.0f - 0.08f) * smoothProgress;
|
||||
var b = 0.20f + (0.0f - 0.20f) * smoothProgress;
|
||||
var alpha = 1f - smoothProgress;
|
||||
var gradientColor = new Vector4(r, g, b, alpha);
|
||||
drawList.AddLine(
|
||||
new Vector2(headerStart.X, headerEnd.Y + i),
|
||||
new Vector2(headerStart.X + width, headerEnd.Y + i),
|
||||
ImGui.GetColorU32(gradientColor),
|
||||
1f
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawHeaderText(Vector2 headerStart)
|
||||
{
|
||||
var textX = 20f;
|
||||
var textY = 30f;
|
||||
|
||||
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY));
|
||||
|
||||
using (_uiShared.UidFont.Push())
|
||||
{
|
||||
ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync");
|
||||
}
|
||||
|
||||
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f));
|
||||
ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes");
|
||||
}
|
||||
|
||||
private void DrawHeaderButtons(Vector2 headerStart, float headerWidth)
|
||||
{
|
||||
var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe);
|
||||
var spacing = 8f * ImGuiHelpers.GlobalScale;
|
||||
var rightPadding = 15f * ImGuiHelpers.GlobalScale;
|
||||
var topPadding = 15f * ImGuiHelpers.GlobalScale;
|
||||
var buttonY = headerStart.Y + topPadding;
|
||||
var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X;
|
||||
var discordButtonX = gitButtonX - buttonSize.X - spacing;
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY));
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0)))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f }))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f }))
|
||||
{
|
||||
if (_uiShared.IconButton(FontAwesomeIcon.Comments))
|
||||
{
|
||||
Util.OpenLink("https://discord.gg/dsbjcXMnhA");
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("Join our Discord");
|
||||
}
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY));
|
||||
if (_uiShared.IconButton(FontAwesomeIcon.Code))
|
||||
{
|
||||
Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync");
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("View on Git");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize)
|
||||
{
|
||||
var deltaTime = ImGui.GetIO().DeltaTime;
|
||||
_particleSpawnTimer += deltaTime;
|
||||
|
||||
if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles)
|
||||
{
|
||||
SpawnParticle(bannerSize);
|
||||
_particleSpawnTimer = 0f;
|
||||
}
|
||||
|
||||
if (_random.NextDouble() < 0.003)
|
||||
{
|
||||
SpawnShootingStar(bannerSize);
|
||||
}
|
||||
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
for (int i = _particles.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var particle = _particles[i];
|
||||
|
||||
var screenPos = bannerStart + particle.Position;
|
||||
|
||||
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null)
|
||||
{
|
||||
particle.Trail.Insert(0, particle.Position);
|
||||
if (particle.Trail.Count > _maxTrailLength)
|
||||
particle.Trail.RemoveAt(particle.Trail.Count - 1);
|
||||
}
|
||||
|
||||
if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
particle.Twinkle += 0.005f * particle.Depth;
|
||||
}
|
||||
|
||||
particle.Position += particle.Velocity * deltaTime;
|
||||
particle.Life -= deltaTime;
|
||||
|
||||
var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 ||
|
||||
particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50;
|
||||
|
||||
if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds))
|
||||
{
|
||||
_particles.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
if (particle.Position.X < 0 || particle.Position.X > bannerSize.X)
|
||||
particle.Velocity = particle.Velocity with { X = -particle.Velocity.X };
|
||||
if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y)
|
||||
particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y };
|
||||
}
|
||||
|
||||
var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f);
|
||||
var fadeOut = Math.Min(1f, particle.Life / 20f);
|
||||
var lifeFade = Math.Min(fadeIn, fadeOut);
|
||||
|
||||
var edgeFadeX = Math.Min(
|
||||
Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance),
|
||||
Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance)
|
||||
);
|
||||
var edgeFadeY = Math.Min(
|
||||
Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance),
|
||||
Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance)
|
||||
);
|
||||
var edgeFade = Math.Min(edgeFadeX, edgeFadeY);
|
||||
|
||||
var baseAlpha = lifeFade * edgeFade;
|
||||
var finalAlpha = particle.Type == ParticleType.TwinklingStar
|
||||
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
||||
: baseAlpha;
|
||||
|
||||
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
|
||||
{
|
||||
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
|
||||
|
||||
for (int t = 1; t < particle.Trail.Count; t++)
|
||||
{
|
||||
var trailProgress = (float)t / particle.Trail.Count;
|
||||
var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f);
|
||||
var trailWidth = (1f - trailProgress) * 3f + 1f;
|
||||
|
||||
var glowAlpha = trailAlpha * 0.4f;
|
||||
drawList.AddLine(
|
||||
bannerStart + particle.Trail[t - 1],
|
||||
bannerStart + particle.Trail[t],
|
||||
ImGui.GetColorU32(cyanColor with { W = glowAlpha }),
|
||||
trailWidth + 4f
|
||||
);
|
||||
|
||||
drawList.AddLine(
|
||||
bannerStart + particle.Trail[t - 1],
|
||||
bannerStart + particle.Trail[t],
|
||||
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
|
||||
trailWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (particle.Type == ParticleType.TwinklingStar)
|
||||
{
|
||||
DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth);
|
||||
}
|
||||
|
||||
_particles[i] = particle;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha,
|
||||
float depth)
|
||||
{
|
||||
var color = HslToRgb(hue, 1.0f, 0.85f);
|
||||
color.W = alpha;
|
||||
|
||||
drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color));
|
||||
|
||||
var glowColor = color with { W = alpha * 0.3f };
|
||||
drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor));
|
||||
}
|
||||
|
||||
private static Vector4 HslToRgb(float h, float s, float l)
|
||||
{
|
||||
h = h / 360f;
|
||||
float c = (1 - MathF.Abs(2 * l - 1)) * s;
|
||||
float x = c * (1 - MathF.Abs((h * 6) % 2 - 1));
|
||||
float m = l - c / 2;
|
||||
|
||||
float r, g, b;
|
||||
if (h < 1f / 6f)
|
||||
{
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
}
|
||||
else if (h < 2f / 6f)
|
||||
{
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
}
|
||||
else if (h < 3f / 6f)
|
||||
{
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
}
|
||||
else if (h < 4f / 6f)
|
||||
{
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
}
|
||||
else if (h < 5f / 6f)
|
||||
{
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
}
|
||||
else
|
||||
{
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return new Vector4(r + m, g + m, b + m, 1.0f);
|
||||
}
|
||||
|
||||
|
||||
private void SpawnParticle(Vector2 bannerSize)
|
||||
{
|
||||
var position = new Vector2(
|
||||
(float)_random.NextDouble() * bannerSize.X,
|
||||
(float)_random.NextDouble() * bannerSize.Y
|
||||
);
|
||||
|
||||
var depthLayers = new[] { 0.5f, 1.0f, 1.5f };
|
||||
var depth = depthLayers[_random.Next(depthLayers.Length)];
|
||||
|
||||
var velocity = new Vector2(
|
||||
((float)_random.NextDouble() - 0.5f) * 0.05f * depth,
|
||||
((float)_random.NextDouble() - 0.5f) * 0.05f * depth
|
||||
);
|
||||
|
||||
var isBlue = _random.NextDouble() < 0.5;
|
||||
var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f;
|
||||
var size = (0.5f + (float)_random.NextDouble() * 2f) * depth;
|
||||
var maxLife = 120f + (float)_random.NextDouble() * 60f;
|
||||
|
||||
_particles.Add(new Particle
|
||||
{
|
||||
Position = position,
|
||||
Velocity = velocity,
|
||||
Life = maxLife,
|
||||
MaxLife = maxLife,
|
||||
Size = size,
|
||||
Type = ParticleType.TwinklingStar,
|
||||
Trail = null,
|
||||
Twinkle = (float)_random.NextDouble() * MathF.PI * 2,
|
||||
Depth = depth,
|
||||
Hue = hue
|
||||
});
|
||||
}
|
||||
|
||||
private void SpawnShootingStar(Vector2 bannerSize)
|
||||
{
|
||||
var maxLife = 80f + (float)_random.NextDouble() * 40f;
|
||||
var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f);
|
||||
var startY = -10f;
|
||||
|
||||
_particles.Add(new Particle
|
||||
{
|
||||
Position = new Vector2(startX, startY),
|
||||
Velocity = new Vector2(
|
||||
-50f - (float)_random.NextDouble() * 40f,
|
||||
30f + (float)_random.NextDouble() * 40f
|
||||
),
|
||||
Life = maxLife,
|
||||
MaxLife = maxLife,
|
||||
Size = 2.5f,
|
||||
Type = ParticleType.ShootingStar,
|
||||
Trail = new List<Vector2>(),
|
||||
Twinkle = 0,
|
||||
Depth = 1.0f,
|
||||
Hue = 270f
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void DrawTabs()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Tab, UIColors.Get("ButtonDefault")))
|
||||
using (ImRaii.PushColor(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushColor(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive")))
|
||||
{
|
||||
using (var tabBar = ImRaii.TabBar("###ll_tabs", ImGuiTabBarFlags.None))
|
||||
{
|
||||
if (!tabBar)
|
||||
return;
|
||||
|
||||
using (var changelogTab = ImRaii.TabItem("Changelog"))
|
||||
{
|
||||
if (changelogTab)
|
||||
{
|
||||
_selectedTab = 0;
|
||||
DrawChangelog();
|
||||
}
|
||||
}
|
||||
|
||||
if (_credits.Credits != null && _credits.Credits.Count > 0)
|
||||
{
|
||||
using (var creditsTab = ImRaii.TabItem("Credits"))
|
||||
{
|
||||
if (creditsTab)
|
||||
{
|
||||
_selectedTab = 1;
|
||||
DrawCredits();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCredits()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
|
||||
using (var child = ImRaii.Child("###ll_credits", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
|
||||
ImGuiWindowFlags.AlwaysVerticalScrollbar))
|
||||
{
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
ImGui.PushTextWrapPos();
|
||||
|
||||
if (_credits.Credits != null)
|
||||
{
|
||||
foreach (var category in _credits.Credits)
|
||||
{
|
||||
DrawCreditCategory(category);
|
||||
ImGuiHelpers.ScaledDummy(10);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCreditCategory(CreditCategory category)
|
||||
{
|
||||
DrawFeatureSection(category.Category, UIColors.Get("LightlessBlue"));
|
||||
|
||||
foreach (var item in category.Items)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Role))
|
||||
{
|
||||
ImGui.BulletText($"{item.Name} — {item.Role}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.BulletText(item.Name);
|
||||
}
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
}
|
||||
|
||||
private void DrawCloseButton()
|
||||
{
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
var closeWidth = 200f * ImGuiHelpers.GlobalScale;
|
||||
var closeHeight = 35f * ImGuiHelpers.GlobalScale;
|
||||
ImGui.SetCursorPosX((ImGui.GetWindowSize().X - closeWidth) / 2);
|
||||
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 8f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurpleActive")))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("ButtonDefault")))
|
||||
{
|
||||
if (ImGui.Button("Got it!", new Vector2(closeWidth, closeHeight)))
|
||||
{
|
||||
// Update last seen version when user acknowledges the update notes
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
|
||||
_configService.Current.LastSeenVersion = currentVersion;
|
||||
_configService.Save();
|
||||
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("You can view this window again in the settings (title menu)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChangelog()
|
||||
{
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
|
||||
using (var child = ImRaii.Child("###ll_changelog", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
|
||||
ImGuiWindowFlags.AlwaysVerticalScrollbar))
|
||||
{
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
if (_scrollToTop)
|
||||
{
|
||||
_scrollToTop = false;
|
||||
ImGui.SetScrollHereY(0);
|
||||
}
|
||||
|
||||
ImGui.PushTextWrapPos();
|
||||
|
||||
foreach (var entry in _changelog.Changelog)
|
||||
{
|
||||
DrawChangelogEntry(entry);
|
||||
}
|
||||
|
||||
_hasInitializedCollapsingHeaders = true;
|
||||
|
||||
ImGui.PopTextWrapPos();
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChangelogEntry(ChangelogEntry entry)
|
||||
{
|
||||
var isCurrent = entry.IsCurrent ?? false;
|
||||
|
||||
var currentColor = isCurrent
|
||||
? UIColors.Get("LightlessGreen")
|
||||
: new Vector4(0.95f, 0.95f, 1.0f, 1.0f);
|
||||
|
||||
var flags = isCurrent ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None;
|
||||
|
||||
if (!_hasInitializedCollapsingHeaders)
|
||||
{
|
||||
ImGui.SetNextItemOpen(isCurrent, ImGuiCond.Always);
|
||||
}
|
||||
|
||||
bool isOpen;
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f))
|
||||
using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("ButtonDefault")))
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, currentColor))
|
||||
{
|
||||
isOpen = ImGui.CollapsingHeader($" {entry.Name} — {entry.Date} ", flags);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.95f, 1.0f), $" — {entry.Tagline}");
|
||||
}
|
||||
|
||||
if (!isOpen)
|
||||
return;
|
||||
|
||||
ImGuiHelpers.ScaledDummy(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.Message))
|
||||
{
|
||||
ImGui.TextWrapped(entry.Message);
|
||||
ImGuiHelpers.ScaledDummy(8);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Versions != null)
|
||||
{
|
||||
foreach (var version in entry.Versions)
|
||||
{
|
||||
DrawFeatureSection(version.Number, UIColors.Get("LightlessGreen"));
|
||||
|
||||
foreach (var item in version.Items)
|
||||
{
|
||||
ImGui.BulletText(item);
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawFeatureSection(string title, Vector4 accentColor)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var startPos = ImGui.GetCursorScreenPos();
|
||||
var availableWidth = ImGui.GetContentRegionAvail().X;
|
||||
|
||||
var backgroundMin = startPos + new Vector2(-8, -4);
|
||||
var backgroundMax = startPos + new Vector2(availableWidth + 8, 28);
|
||||
|
||||
var bgColor = new Vector4(0.12f, 0.12f, 0.15f, 0.7f);
|
||||
drawList.AddRectFilled(backgroundMin, backgroundMax, ImGui.GetColorU32(bgColor), 6f);
|
||||
|
||||
drawList.AddRectFilled(
|
||||
backgroundMin,
|
||||
backgroundMin + new Vector2(4, backgroundMax.Y - backgroundMin.Y),
|
||||
ImGui.GetColorU32(accentColor),
|
||||
3f
|
||||
);
|
||||
|
||||
var glowColor = accentColor with { W = 0.15f };
|
||||
drawList.AddRect(
|
||||
backgroundMin,
|
||||
backgroundMax,
|
||||
ImGui.GetColorU32(glowColor),
|
||||
6f,
|
||||
ImDrawFlags.None,
|
||||
1.5f
|
||||
);
|
||||
|
||||
// Calculate vertical centering
|
||||
var textSize = ImGui.CalcTextSize(title);
|
||||
var boxHeight = backgroundMax.Y - backgroundMin.Y;
|
||||
var verticalOffset = (boxHeight - textSize.Y) / 5f;
|
||||
|
||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 8);
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + verticalOffset);
|
||||
ImGui.TextColored(accentColor, title);
|
||||
ImGui.SetCursorPosY(backgroundMax.Y - startPos.Y + ImGui.GetCursorPosY());
|
||||
}
|
||||
|
||||
private void LoadEmbeddedResources()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
// Load changelog
|
||||
using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.changelog.yaml");
|
||||
if (changelogStream != null)
|
||||
{
|
||||
using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128);
|
||||
var yaml = reader.ReadToEnd();
|
||||
_changelog = deserializer.Deserialize<ChangelogFile>(yaml) ?? new();
|
||||
}
|
||||
|
||||
// Load credits
|
||||
using var creditsStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.credits.yaml");
|
||||
if (creditsStream != null)
|
||||
{
|
||||
using var reader = new StreamReader(creditsStream, Encoding.UTF8, true, 128);
|
||||
var yaml = reader.ReadToEnd();
|
||||
_credits = deserializer.Deserialize<CreditsFile>(yaml) ?? new();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore - window will gracefully render with defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
99
LightlessSync/Utils/AtkNodeHelpers.cs
Normal file
99
LightlessSync/Utils/AtkNodeHelpers.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Memory;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
|
||||
namespace LightlessSync.Utils;
|
||||
|
||||
internal static unsafe class AtkNodeHelpers
|
||||
{
|
||||
internal const ushort DefaultTextNodeWidth = 200;
|
||||
internal const ushort DefaultTextNodeHeight = 14;
|
||||
|
||||
internal static AtkTextNode* CreateNewTextNode(AtkUnitBase* pAddon, uint nodeID)
|
||||
{
|
||||
if (pAddon == null) return null;
|
||||
var pNewNode = CreateOrphanTextNode(nodeID);
|
||||
if (pNewNode != null) AttachTextNode(pAddon, pNewNode);
|
||||
return pNewNode;
|
||||
}
|
||||
|
||||
internal static void HideNode(AtkUnitBase* pAddon, uint nodeID)
|
||||
{
|
||||
var pNode = GetTextNodeByID(pAddon, nodeID);
|
||||
if (pNode != null) ((AtkResNode*)pNode)->ToggleVisibility(false);
|
||||
}
|
||||
|
||||
internal static AtkTextNode* GetTextNodeByID(AtkUnitBase* pAddon, uint nodeID)
|
||||
{
|
||||
if (pAddon == null) return null;
|
||||
for (var i = 0; i < pAddon->UldManager.NodeListCount; ++i)
|
||||
{
|
||||
if (pAddon->UldManager.NodeList[i] == null) continue;
|
||||
if (pAddon->UldManager.NodeList[i]->NodeId == nodeID)
|
||||
{
|
||||
return (AtkTextNode*)pAddon->UldManager.NodeList[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static void AttachTextNode(AtkUnitBase* pAddon, AtkTextNode* pNode)
|
||||
{
|
||||
if (pAddon == null) return;
|
||||
|
||||
if (pNode != null)
|
||||
{
|
||||
var lastNode = pAddon->RootNode;
|
||||
if (lastNode->ChildNode != null)
|
||||
{
|
||||
lastNode = lastNode->ChildNode;
|
||||
while (lastNode->PrevSiblingNode != null)
|
||||
{
|
||||
lastNode = lastNode->PrevSiblingNode;
|
||||
}
|
||||
|
||||
pNode->AtkResNode.NextSiblingNode = lastNode;
|
||||
pNode->AtkResNode.ParentNode = pAddon->RootNode;
|
||||
lastNode->PrevSiblingNode = (AtkResNode*)pNode;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastNode->ChildNode = (AtkResNode*)pNode;
|
||||
pNode->AtkResNode.ParentNode = lastNode;
|
||||
}
|
||||
|
||||
pAddon->UldManager.UpdateDrawNodeList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static AtkTextNode* CreateOrphanTextNode(uint nodeID, TextFlags textFlags = TextFlags.Edge)
|
||||
{
|
||||
var pNewNode = (AtkTextNode*)IMemorySpace.GetUISpace()->Malloc((ulong)sizeof(AtkTextNode), 8);
|
||||
if (pNewNode != null)
|
||||
{
|
||||
IMemorySpace.Memset(pNewNode, 0, (ulong)sizeof(AtkTextNode));
|
||||
pNewNode->Ctor();
|
||||
|
||||
pNewNode->AtkResNode.Type = NodeType.Text;
|
||||
pNewNode->AtkResNode.NodeFlags = NodeFlags.AnchorLeft | NodeFlags.AnchorTop;
|
||||
pNewNode->AtkResNode.DrawFlags = 0;
|
||||
pNewNode->AtkResNode.SetPositionShort(0, 0);
|
||||
pNewNode->AtkResNode.SetWidth(DefaultTextNodeWidth);
|
||||
pNewNode->AtkResNode.SetHeight(DefaultTextNodeHeight);
|
||||
|
||||
pNewNode->LineSpacing = 24;
|
||||
pNewNode->CharSpacing = 1;
|
||||
pNewNode->AlignmentFontType = (byte)AlignmentType.BottomLeft;
|
||||
pNewNode->FontSize = 12;
|
||||
pNewNode->TextFlags = textFlags;
|
||||
|
||||
pNewNode->AtkResNode.NodeId = nodeID;
|
||||
|
||||
pNewNode->AtkResNode.Color.A = 0xFF;
|
||||
pNewNode->AtkResNode.Color.R = 0xFF;
|
||||
pNewNode->AtkResNode.Color.G = 0xFF;
|
||||
pNewNode->AtkResNode.Color.B = 0xFF;
|
||||
}
|
||||
|
||||
return pNewNode;
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,39 @@ namespace LightlessSync.Utils;
|
||||
|
||||
public static class Crypto
|
||||
{
|
||||
//This buffersize seems to be the best sweetpoint for Linux and Windows
|
||||
private const int _bufferSize = 65536;
|
||||
#pragma warning disable SYSLIB0021 // Type or member is obsolete
|
||||
|
||||
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = new();
|
||||
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = [];
|
||||
private static readonly Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
|
||||
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
|
||||
|
||||
public static string GetFileHash(this string filePath)
|
||||
{
|
||||
using SHA1CryptoServiceProvider cryptoProvider = new();
|
||||
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal);
|
||||
using SHA1 sha1 = SHA1.Create();
|
||||
using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static async Task<string> GetFileHashAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
|
||||
var buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0);
|
||||
}
|
||||
|
||||
sha1.TransformFinalBlock([], 0, 0);
|
||||
|
||||
return Convert.ToHexString(sha1.Hash!);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetHash256(this (string, ushort) playerToHash)
|
||||
|
||||
286
LightlessSync/Utils/FileSystemHelper.cs
Normal file
286
LightlessSync/Utils/FileSystemHelper.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.Utils
|
||||
{
|
||||
public static class FileSystemHelper
|
||||
{
|
||||
public enum FilesystemType
|
||||
{
|
||||
Unknown = 0,
|
||||
NTFS, // Compressable on file level
|
||||
Btrfs, // Compressable on file level
|
||||
Ext4, // Uncompressable
|
||||
Xfs, // Uncompressable
|
||||
Apfs, // Compressable on OS
|
||||
HfsPlus, // Compressable on OS
|
||||
Fat, // Uncompressable
|
||||
Exfat, // Uncompressable
|
||||
Zfs // Compressable, not on file level
|
||||
}
|
||||
|
||||
private const string _mountPath = "/proc/mounts";
|
||||
private const int _defaultBlockSize = 4096;
|
||||
private static readonly Dictionary<string, int> _blockSizeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, FilesystemType> _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static FilesystemType GetFilesystemType(string filePath, bool isWine = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
string rootPath;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var dir = info.Directory ?? new DirectoryInfo(filePath);
|
||||
rootPath = dir.Root.FullName;
|
||||
}
|
||||
else
|
||||
{
|
||||
rootPath = GetMountPoint(filePath);
|
||||
if (string.IsNullOrEmpty(rootPath))
|
||||
rootPath = "/";
|
||||
}
|
||||
|
||||
if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType))
|
||||
return cachedType;
|
||||
|
||||
FilesystemType detected;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
|
||||
{
|
||||
var root = new DriveInfo(rootPath);
|
||||
var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
detected = format switch
|
||||
{
|
||||
"NTFS" => FilesystemType.NTFS,
|
||||
"FAT32" => FilesystemType.Fat,
|
||||
"EXFAT" => FilesystemType.Exfat,
|
||||
_ => FilesystemType.Unknown
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
detected = GetLinuxFilesystemType(filePath);
|
||||
}
|
||||
|
||||
if (isWine || IsProbablyWine())
|
||||
{
|
||||
switch (detected)
|
||||
{
|
||||
case FilesystemType.NTFS:
|
||||
case FilesystemType.Unknown:
|
||||
{
|
||||
var linuxDetected = GetLinuxFilesystemType(filePath);
|
||||
if (linuxDetected != FilesystemType.Unknown)
|
||||
{
|
||||
detected = linuxDetected;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_filesystemTypeCache[rootPath] = detected;
|
||||
return detected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMountPoint(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(_mountPath)) return "/";
|
||||
var mounts = File.ReadAllLines(_mountPath);
|
||||
|
||||
string bestMount = "/";
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 3) continue;
|
||||
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
|
||||
string normalizedMount;
|
||||
try { normalizedMount = Path.GetFullPath(mountPoint); }
|
||||
catch { normalizedMount = mountPoint; }
|
||||
|
||||
if (path.StartsWith(normalizedMount, StringComparison.Ordinal) &&
|
||||
normalizedMount.Length > bestMount.Length)
|
||||
{
|
||||
bestMount = normalizedMount;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetMountOptionsForPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var mounts = File.ReadAllLines("/proc/mounts");
|
||||
string bestMount = string.Empty;
|
||||
string mountOptions = string.Empty;
|
||||
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 4) continue;
|
||||
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
string normalized;
|
||||
try { normalized = Path.GetFullPath(mountPoint); }
|
||||
catch { normalized = mountPoint; }
|
||||
|
||||
if (fullPath.StartsWith(normalized, StringComparison.Ordinal) &&
|
||||
normalized.Length > bestMount.Length)
|
||||
{
|
||||
bestMount = normalized;
|
||||
mountOptions = parts[3];
|
||||
}
|
||||
}
|
||||
|
||||
return mountOptions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static FilesystemType GetLinuxFilesystemType(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mountPoint = GetMountPoint(filePath);
|
||||
var mounts = File.ReadAllLines(_mountPath);
|
||||
|
||||
foreach (var line in mounts)
|
||||
{
|
||||
var parts = line.Split(' ');
|
||||
if (parts.Length < 3) continue;
|
||||
var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
|
||||
if (string.Equals(mount, mountPoint, StringComparison.Ordinal))
|
||||
{
|
||||
var fstype = parts[2].ToLowerInvariant();
|
||||
return fstype switch
|
||||
{
|
||||
"btrfs" => FilesystemType.Btrfs,
|
||||
"ext4" => FilesystemType.Ext4,
|
||||
"xfs" => FilesystemType.Xfs,
|
||||
"zfs" => FilesystemType.Zfs,
|
||||
"apfs" => FilesystemType.Apfs,
|
||||
"hfsplus" => FilesystemType.HfsPlus,
|
||||
_ => FilesystemType.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return FilesystemType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return _defaultBlockSize;
|
||||
|
||||
var fi = new FileInfo(path);
|
||||
if (!fi.Exists)
|
||||
return _defaultBlockSize;
|
||||
|
||||
var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/";
|
||||
if (_blockSizeCache.TryGetValue(root, out int cached))
|
||||
return cached;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine)
|
||||
{
|
||||
int result = GetDiskFreeSpaceW(root,
|
||||
out uint sectorsPerCluster,
|
||||
out uint bytesPerSector,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
logger?.LogWarning("Failed to determine block size for {root}", root);
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
|
||||
int clusterSize = (int)(sectorsPerCluster * bytesPerSector);
|
||||
_blockSizeCache[root] = clusterSize;
|
||||
logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize);
|
||||
return clusterSize;
|
||||
}
|
||||
|
||||
string realPath = fi.FullName;
|
||||
if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
realPath = "/" + realPath.Substring(3).Replace('\\', '/');
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/bash",
|
||||
Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = "/"
|
||||
};
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
|
||||
string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
string _stderr = proc?.StandardError.ReadToEnd() ?? "";
|
||||
|
||||
try { proc?.WaitForExit(); }
|
||||
catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); }
|
||||
|
||||
if (!(!int.TryParse(stdout, out int block) || block <= 0))
|
||||
{
|
||||
_blockSizeCache[root] = block;
|
||||
logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout);
|
||||
_blockSizeCache[root] = _defaultBlockSize;
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogTrace(ex, "Error determining block size for {path}", path);
|
||||
return _defaultBlockSize;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)]
|
||||
private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters);
|
||||
|
||||
//Extra check on
|
||||
public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts");
|
||||
}
|
||||
}
|
||||
275
LightlessSync/Utils/SeStringUtils.cs
Normal file
275
LightlessSync/Utils/SeStringUtils.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiSeStringRenderer;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Lumina.Text;
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString;
|
||||
using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder;
|
||||
using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder;
|
||||
|
||||
namespace LightlessSync.Utils;
|
||||
|
||||
public static class SeStringUtils
|
||||
{
|
||||
private static int _seStringHitboxCounter;
|
||||
private static int _iconHitboxCounter;
|
||||
|
||||
public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor)
|
||||
{
|
||||
var b = new DalamudSeStringBuilder();
|
||||
|
||||
if (glowColor is Vector4 glow)
|
||||
b.Add(new GlowPayload(glow));
|
||||
|
||||
if (textColor is Vector4 color)
|
||||
b.Add(new ColorPayload(color));
|
||||
|
||||
b.AddText(text ?? string.Empty);
|
||||
|
||||
if (textColor is not null)
|
||||
b.Add(new ColorEndPayload());
|
||||
|
||||
if (glowColor is not null)
|
||||
b.Add(new GlowEndPayload());
|
||||
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
public static DalamudSeString BuildPlain(string text)
|
||||
{
|
||||
var b = new DalamudSeStringBuilder();
|
||||
b.AddText(text ?? string.Empty);
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
public static DalamudSeString BuildRichText(ReadOnlySpan<RichTextEntry> fragments)
|
||||
{
|
||||
var builder = new LuminaSeStringBuilder();
|
||||
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fragment.Text))
|
||||
continue;
|
||||
|
||||
var hasColor = fragment.Color.HasValue;
|
||||
Vector4 color = default;
|
||||
if (hasColor)
|
||||
{
|
||||
color = fragment.Color!.Value;
|
||||
builder.PushColorRgba(color);
|
||||
}
|
||||
|
||||
if (fragment.Bold)
|
||||
builder.AppendSetBold(true);
|
||||
|
||||
builder.Append(fragment.Text.AsSpan());
|
||||
|
||||
if (fragment.Bold)
|
||||
builder.AppendSetBold(false);
|
||||
|
||||
if (hasColor)
|
||||
builder.PopColor();
|
||||
}
|
||||
|
||||
return DalamudSeString.Parse(builder.ToArray());
|
||||
}
|
||||
|
||||
public static DalamudSeString BuildRichText(params RichTextEntry[] fragments) => BuildRichText(fragments.AsSpan());
|
||||
public static void RenderSeString(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, ImDrawListPtr? drawList = null)
|
||||
{
|
||||
drawList ??= ImGui.GetWindowDrawList();
|
||||
|
||||
var drawParams = new SeStringDrawParams
|
||||
{
|
||||
Font = font ?? UiBuilder.MonoFont,
|
||||
Color = 0xFFFFFFFF,
|
||||
WrapWidth = float.MaxValue,
|
||||
TargetDrawList = drawList
|
||||
};
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
|
||||
|
||||
var textSize = ImGui.CalcTextSize(seString.TextValue);
|
||||
if (textSize.Y <= 0f)
|
||||
textSize.Y = ImGui.GetTextLineHeight();
|
||||
|
||||
ImGui.Dummy(new Vector2(0f, textSize.Y));
|
||||
}
|
||||
|
||||
public static void RenderSeStringWrapped(DalamudSeString seString, float wrapWidth, ImFontPtr? font = null, ImDrawListPtr? drawList = null)
|
||||
{
|
||||
drawList ??= ImGui.GetWindowDrawList();
|
||||
|
||||
var drawParams = new SeStringDrawParams
|
||||
{
|
||||
Font = font ?? ImGui.GetFont(),
|
||||
Color = ImGui.GetColorU32(ImGuiCol.Text),
|
||||
WrapWidth = wrapWidth,
|
||||
TargetDrawList = drawList
|
||||
};
|
||||
|
||||
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
|
||||
|
||||
var calcWrapWidth = wrapWidth > 0f ? wrapWidth : -1f;
|
||||
var textSize = ImGui.CalcTextSize(seString.TextValue, wrapWidth: calcWrapWidth);
|
||||
if (textSize.Y <= 0f)
|
||||
textSize.Y = ImGui.GetTextLineHeight();
|
||||
|
||||
ImGui.Dummy(new Vector2(0f, textSize.Y));
|
||||
}
|
||||
public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var drawParams = new SeStringDrawParams
|
||||
{
|
||||
Font = font ?? UiBuilder.MonoFont,
|
||||
Color = 0xFFFFFFFF,
|
||||
WrapWidth = float.MaxValue,
|
||||
TargetDrawList = drawList
|
||||
};
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
|
||||
|
||||
var textSize = ImGui.CalcTextSize(seString.TextValue);
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
if (id is not null)
|
||||
{
|
||||
ImGui.PushID(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.PushID(Interlocked.Increment(ref _seStringHitboxCounter));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ImGui.InvisibleButton("##hitbox", textSize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImGui.PopID();
|
||||
}
|
||||
|
||||
return textSize;
|
||||
}
|
||||
|
||||
public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var drawParams = new SeStringDrawParams
|
||||
{
|
||||
Font = font ?? UiBuilder.MonoFont,
|
||||
Color = 0xFFFFFFFF,
|
||||
WrapWidth = float.MaxValue,
|
||||
TargetDrawList = drawList
|
||||
};
|
||||
|
||||
var iconMacro = $"<icon({iconId})>";
|
||||
var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams);
|
||||
|
||||
ImGui.SetCursorScreenPos(position);
|
||||
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;
|
||||
}
|
||||
|
||||
#region Internal Payloads
|
||||
|
||||
public readonly record struct RichTextEntry(string Text, Vector4? Color = null, bool Bold = false);
|
||||
|
||||
private abstract class AbstractColorPayload : Payload
|
||||
{
|
||||
protected byte Red { get; init; }
|
||||
protected byte Green { get; init; }
|
||||
protected byte Blue { get; init; }
|
||||
|
||||
protected override byte[] EncodeImpl()
|
||||
{
|
||||
return new byte[] { 0x02, ChunkType, 0x05, 0xF6, Red, Green, Blue, 0x03 };
|
||||
}
|
||||
|
||||
protected override void DecodeImpl(BinaryReader reader, long endOfStream) { }
|
||||
|
||||
public override PayloadType Type => PayloadType.Unknown;
|
||||
protected abstract byte ChunkType { get; }
|
||||
}
|
||||
|
||||
private abstract class AbstractColorEndPayload : Payload
|
||||
{
|
||||
protected override byte[] EncodeImpl()
|
||||
{
|
||||
return new byte[] { 0x02, ChunkType, 0x02, 0xEC, 0x03 };
|
||||
}
|
||||
|
||||
protected override void DecodeImpl(BinaryReader reader, long endOfStream) { }
|
||||
|
||||
public override PayloadType Type => PayloadType.Unknown;
|
||||
protected abstract byte ChunkType { get; }
|
||||
}
|
||||
|
||||
private class ColorPayload : AbstractColorPayload
|
||||
{
|
||||
protected override byte ChunkType => 0x13;
|
||||
|
||||
public ColorPayload(Vector3 color)
|
||||
{
|
||||
Red = Math.Max((byte)1, (byte)(color.X * 255f));
|
||||
Green = Math.Max((byte)1, (byte)(color.Y * 255f));
|
||||
Blue = Math.Max((byte)1, (byte)(color.Z * 255f));
|
||||
}
|
||||
|
||||
public ColorPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { }
|
||||
}
|
||||
|
||||
private class ColorEndPayload : AbstractColorEndPayload
|
||||
{
|
||||
protected override byte ChunkType => 0x13;
|
||||
}
|
||||
|
||||
private class GlowPayload : AbstractColorPayload
|
||||
{
|
||||
protected override byte ChunkType => 0x14;
|
||||
|
||||
public GlowPayload(Vector3 color)
|
||||
{
|
||||
Red = Math.Max((byte)1, (byte)(color.X * 255f));
|
||||
Green = Math.Max((byte)1, (byte)(color.Y * 255f));
|
||||
Blue = Math.Max((byte)1, (byte)(color.Z * 255f));
|
||||
}
|
||||
|
||||
public GlowPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { }
|
||||
}
|
||||
|
||||
private class GlowEndPayload : AbstractColorEndPayload
|
||||
{
|
||||
protected override byte ChunkType => 0x14;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
15
LightlessSync/Utils/UtilsEnum/LabelAlignment.cs
Normal file
15
LightlessSync/Utils/UtilsEnum/LabelAlignment.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LightlessSync.UtilsEnum.Enum
|
||||
{
|
||||
public enum LabelAlignment
|
||||
{
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
using Dalamud.Utility;
|
||||
using Dalamud.Utility;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto.Files;
|
||||
using LightlessSync.API.Routes;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.WebAPI.Files.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
|
||||
namespace LightlessSync.WebAPI.Files;
|
||||
|
||||
@@ -19,24 +26,34 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
private readonly FileCompactor _fileCompactor;
|
||||
private readonly FileCacheManager _fileDbManager;
|
||||
private readonly FileTransferOrchestrator _orchestrator;
|
||||
private readonly List<ThrottledStream> _activeDownloadStreams;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
||||
private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30);
|
||||
private volatile bool _disableDirectDownloads;
|
||||
private int _consecutiveDirectDownloadFailures;
|
||||
private bool _lastConfigDirectDownloadsState;
|
||||
|
||||
public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
|
||||
FileTransferOrchestrator orchestrator,
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator)
|
||||
FileCacheManager fileCacheManager, FileCompactor fileCompactor,
|
||||
PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService) : base(logger, mediator)
|
||||
{
|
||||
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
|
||||
_orchestrator = orchestrator;
|
||||
_fileDbManager = fileCacheManager;
|
||||
_fileCompactor = fileCompactor;
|
||||
_activeDownloadStreams = [];
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_configService = configService;
|
||||
_activeDownloadStreams = new();
|
||||
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
|
||||
|
||||
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!_activeDownloadStreams.Any()) return;
|
||||
if (_activeDownloadStreams.IsEmpty) return;
|
||||
var newLimit = _orchestrator.DownloadLimitPerSlot();
|
||||
Logger.LogTrace("Setting new Download Speed Limit to {newLimit}", newLimit);
|
||||
foreach (var stream in _activeDownloadStreams)
|
||||
foreach (var stream in _activeDownloadStreams.Keys)
|
||||
{
|
||||
stream.BandwidthLimit = newLimit;
|
||||
}
|
||||
@@ -47,7 +64,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -84,7 +106,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
ClearDownload();
|
||||
foreach (var stream in _activeDownloadStreams.ToList())
|
||||
foreach (var stream in _activeDownloadStreams.Keys.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -95,6 +117,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
// do nothing
|
||||
//
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activeDownloadStreams.TryRemove(stream, out _);
|
||||
}
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
@@ -142,41 +168,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
await WaitForDownloadReady(fileTransfer, requestId, ct).ConfigureAwait(false);
|
||||
|
||||
_downloadStatus[downloadGroup].DownloadStatus = DownloadStatus.Downloading;
|
||||
if (_downloadStatus.TryGetValue(downloadGroup, out var downloadStatus))
|
||||
{
|
||||
downloadStatus.DownloadStatus = DownloadStatus.Downloading;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup);
|
||||
}
|
||||
|
||||
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
|
||||
|
||||
await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private delegate void DownloadDataCallback(Span<byte> data);
|
||||
|
||||
private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress<long> progress, DownloadDataCallback? callback, CancellationToken ct, bool withToken)
|
||||
{
|
||||
const int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
TimeSpan retryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
HttpResponseMessage response = null!;
|
||||
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl} for request {id}", retryCount + 1, requestUrl, requestId);
|
||||
|
||||
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl);
|
||||
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null)
|
||||
{
|
||||
response?.Dispose();
|
||||
retryCount++;
|
||||
|
||||
Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
|
||||
|
||||
if (retryCount >= maxRetries || ct.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogError($"Max retries reached or cancelled. Failing download for {requestUrl}");
|
||||
Logger.LogError("Max retries reached or cancelled. Failing download for {requestUrl}", requestUrl);
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false); // Wait before retrying
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
response?.Dispose();
|
||||
retryCount++;
|
||||
|
||||
Logger.LogWarning(ex, "Cancellation/timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
|
||||
|
||||
if (retryCount >= maxRetries)
|
||||
{
|
||||
Logger.LogError("Max retries reached for {requestUrl} after TaskCanceledException", requestUrl);
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(retryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
response?.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
response?.Dispose();
|
||||
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
|
||||
|
||||
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
|
||||
@@ -184,42 +245,80 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex);
|
||||
}
|
||||
|
||||
throw;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ThrottledStream? stream = null;
|
||||
FileStream? fileStream = null;
|
||||
|
||||
try
|
||||
{
|
||||
fileStream = File.Create(tempPath);
|
||||
fileStream = File.Create(destinationFilename);
|
||||
await using (fileStream.ConfigureAwait(false))
|
||||
{
|
||||
var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
|
||||
var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
|
||||
var buffer = new byte[bufferSize];
|
||||
|
||||
var bytesRead = 0;
|
||||
var limit = _orchestrator.DownloadLimitPerSlot();
|
||||
Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath);
|
||||
Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename);
|
||||
|
||||
stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
|
||||
_activeDownloadStreams.TryAdd(stream, 0);
|
||||
|
||||
_activeDownloadStreams.Add(stream);
|
||||
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
int bytesRead;
|
||||
try
|
||||
{
|
||||
var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask();
|
||||
while (!readTask.IsCompleted)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false);
|
||||
if (completedTask == readTask)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
MungeBuffer(buffer.AsSpan(0, bytesRead));
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var snapshot = _pairProcessingLimiter.GetSnapshot();
|
||||
if (snapshot.Waiting > 0)
|
||||
{
|
||||
throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})");
|
||||
}
|
||||
|
||||
Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl);
|
||||
}
|
||||
|
||||
bytesRead = await readTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
callback?.Invoke(buffer.AsSpan(0, bytesRead));
|
||||
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
|
||||
|
||||
progress.Report(bytesRead);
|
||||
}
|
||||
|
||||
Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath);
|
||||
Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename);
|
||||
}
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
@@ -228,36 +327,181 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
try
|
||||
{
|
||||
fileStream?.Close();
|
||||
fileStream?.Close();
|
||||
|
||||
if (!string.IsNullOrEmpty(tempPath) && File.Exists(tempPath))
|
||||
if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
File.Delete(destinationFilename);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
// ignore cleanup errors
|
||||
}
|
||||
throw;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stream != null)
|
||||
{
|
||||
_activeDownloadStreams.Remove(stream);
|
||||
_activeDownloadStreams.TryRemove(stream, out _);
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List<FileReplacementData> fileReplacement, string downloadLabel)
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(downloadStatusKey, out var status))
|
||||
{
|
||||
status.TransferredFiles = 1;
|
||||
status.DownloadStatus = DownloadStatus.Decompressing;
|
||||
}
|
||||
|
||||
FileStream? fileBlockStream = null;
|
||||
try
|
||||
{
|
||||
fileBlockStream = File.OpenRead(blockFilePath);
|
||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||
{
|
||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||
|
||||
try
|
||||
{
|
||||
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
|
||||
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||
|
||||
byte[] compressedFileContent = new byte[fileLengthBytes];
|
||||
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
|
||||
if (readBytes != fileLengthBytes)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
MungeBuffer(compressedFileContent);
|
||||
|
||||
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
PersistFileToStorage(fileHash, filePath);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (fileBlockStream != null)
|
||||
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List<FileReplacementData> fileReplacement,
|
||||
IProgress<long> progress, CancellationToken token, bool slotAlreadyAcquired)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Direct download fallback requested without a direct download URL.");
|
||||
}
|
||||
|
||||
var downloadKey = directDownload.DirectDownloadUrl!;
|
||||
bool slotAcquiredHere = false;
|
||||
string? blockFile = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (!slotAlreadyAcquired)
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(downloadKey, out var tracker))
|
||||
{
|
||||
tracker.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
}
|
||||
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
slotAcquiredHere = true;
|
||||
}
|
||||
|
||||
if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker))
|
||||
{
|
||||
queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
}
|
||||
|
||||
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri),
|
||||
new[] { directDownload.Hash }, token).ConfigureAwait(false);
|
||||
var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"'));
|
||||
|
||||
blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk");
|
||||
|
||||
await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false);
|
||||
|
||||
if (!File.Exists(blockFile))
|
||||
{
|
||||
throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile);
|
||||
}
|
||||
|
||||
await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (slotAcquiredHere)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(blockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(blockFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
|
||||
{
|
||||
Logger.LogDebug("Download start: {id}", gameObjectHandler.Name);
|
||||
var objectName = gameObjectHandler?.Name ?? "Unknown";
|
||||
Logger.LogDebug("Download start: {id}", objectName);
|
||||
|
||||
if (fileReplacement == null || fileReplacement.Count == 0)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: No file replacements provided", objectName);
|
||||
CurrentDownloads = [];
|
||||
return CurrentDownloads;
|
||||
}
|
||||
|
||||
var hashes = fileReplacement.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Hash)).Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: No valid hashes to download", objectName);
|
||||
CurrentDownloads = [];
|
||||
return CurrentDownloads;
|
||||
}
|
||||
|
||||
List<DownloadFileDto> downloadFileInfoFromService =
|
||||
[
|
||||
.. await FilesGetSizes(fileReplacement.Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList(), ct).ConfigureAwait(false),
|
||||
.. await FilesGetSizes(hashes, ct).ConfigureAwait(false),
|
||||
];
|
||||
|
||||
Logger.LogDebug("Files with size 0 or less: {files}", string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash)));
|
||||
@@ -278,30 +522,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
|
||||
{
|
||||
var downloadGroups = CurrentDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal);
|
||||
var objectName = gameObjectHandler?.Name ?? "Unknown";
|
||||
|
||||
foreach (var downloadGroup in downloadGroups)
|
||||
var configAllowsDirect = _configService.Current.EnableDirectDownloads;
|
||||
if (configAllowsDirect != _lastConfigDirectDownloadsState)
|
||||
{
|
||||
_downloadStatus[downloadGroup.Key] = new FileDownloadStatus()
|
||||
_lastConfigDirectDownloadsState = configAllowsDirect;
|
||||
if (configAllowsDirect)
|
||||
{
|
||||
_disableDirectDownloads = false;
|
||||
_consecutiveDirectDownloadFailures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
var allowDirectDownloads = ShouldUseDirectDownloads();
|
||||
|
||||
var directDownloads = new List<DownloadFileTransfer>();
|
||||
var batchDownloads = new List<DownloadFileTransfer>();
|
||||
|
||||
foreach (var download in CurrentDownloads)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads)
|
||||
{
|
||||
directDownloads.Add(download);
|
||||
}
|
||||
else
|
||||
{
|
||||
batchDownloads.Add(download);
|
||||
}
|
||||
}
|
||||
|
||||
var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray();
|
||||
|
||||
foreach (var directDownload in directDownloads)
|
||||
{
|
||||
_downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus()
|
||||
{
|
||||
DownloadStatus = DownloadStatus.Initializing,
|
||||
TotalBytes = downloadGroup.Sum(c => c.Total),
|
||||
TotalBytes = directDownload.Total,
|
||||
TotalFiles = 1,
|
||||
TransferredBytes = 0,
|
||||
TransferredFiles = 0
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var downloadBatch in downloadBatches)
|
||||
{
|
||||
_downloadStatus[downloadBatch.Key] = new FileDownloadStatus()
|
||||
{
|
||||
DownloadStatus = DownloadStatus.Initializing,
|
||||
TotalBytes = downloadBatch.Sum(c => c.Total),
|
||||
TotalFiles = 1,
|
||||
TransferredBytes = 0,
|
||||
TransferredFiles = 0
|
||||
};
|
||||
}
|
||||
|
||||
if (directDownloads.Count > 0 || downloadBatches.Length > 0)
|
||||
{
|
||||
Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length);
|
||||
}
|
||||
|
||||
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
|
||||
|
||||
await Parallel.ForEachAsync(downloadGroups, new ParallelOptions()
|
||||
Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = downloadGroups.Count(),
|
||||
MaxDegreeOfParallelism = downloadBatches.Length,
|
||||
CancellationToken = ct,
|
||||
},
|
||||
async (fileGroup, token) =>
|
||||
{
|
||||
// let server predownload files
|
||||
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
|
||||
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false);
|
||||
Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri,
|
||||
@@ -315,15 +605,23 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
FileInfo fi = new(blockFile);
|
||||
try
|
||||
{
|
||||
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
if (!_downloadStatus.TryGetValue(fileGroup.Key, out var downloadStatus))
|
||||
{
|
||||
Logger.LogWarning("Download status missing for {group}, aborting", fileGroup.Key);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
Progress<long> progress = new((bytesDownloaded) =>
|
||||
downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
var progress = CreateInlineProgress((bytesDownloaded) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value)) return;
|
||||
value.TransferredBytes += bytesDownloaded;
|
||||
if (_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value))
|
||||
{
|
||||
value.TransferredBytes += bytesDownloaded;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -334,7 +632,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler);
|
||||
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -345,66 +643,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
FileStream? fileBlockStream = null;
|
||||
try
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
|
||||
if (!File.Exists(blockFile))
|
||||
{
|
||||
status.TransferredFiles = 1;
|
||||
status.DownloadStatus = DownloadStatus.Decompressing;
|
||||
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
|
||||
return;
|
||||
}
|
||||
fileBlockStream = File.OpenRead(blockFile);
|
||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||
{
|
||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||
|
||||
try
|
||||
{
|
||||
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
|
||||
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath);
|
||||
|
||||
byte[] compressedFileContent = new byte[fileLengthBytes];
|
||||
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
|
||||
if (readBytes != fileLengthBytes)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
MungeBuffer(compressedFileContent);
|
||||
|
||||
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
PersistFileToStorage(fileHash, filePath);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name);
|
||||
await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
if (fileBlockStream != null)
|
||||
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
|
||||
File.Delete(blockFile);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
Logger.LogDebug("Download end: {id}", gameObjectHandler);
|
||||
Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = directDownloads.Count,
|
||||
CancellationToken = ct,
|
||||
},
|
||||
async (directDownload, token) =>
|
||||
{
|
||||
if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker))
|
||||
{
|
||||
Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
var progress = CreateInlineProgress((bytesDownloaded) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value))
|
||||
{
|
||||
value.TransferredBytes += bytesDownloaded;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not set download progress");
|
||||
}
|
||||
});
|
||||
|
||||
if (!ShouldUseDirectDownloads())
|
||||
{
|
||||
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin");
|
||||
var slotAcquired = false;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot;
|
||||
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
|
||||
slotAcquired = true;
|
||||
|
||||
downloadTracker.DownloadStatus = DownloadStatus.Downloading;
|
||||
Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
|
||||
await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false);
|
||||
|
||||
Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0);
|
||||
|
||||
downloadTracker.DownloadStatus = DownloadStatus.Decompressing;
|
||||
|
||||
try
|
||||
{
|
||||
var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase));
|
||||
if (replacement == null || replacement.GamePaths.Length == 0)
|
||||
{
|
||||
Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileExtension = replacement.GamePaths[0].Split(".")[^1];
|
||||
var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension);
|
||||
Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename);
|
||||
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false);
|
||||
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
|
||||
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false);
|
||||
PersistFileToStorage(directDownload.Hash, finalFilename);
|
||||
|
||||
downloadTracker.TransferredFiles = 1;
|
||||
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash);
|
||||
Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash);
|
||||
ClearDownload();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var expectedDirectDownloadFailure = ex is InvalidDataException;
|
||||
var failureCount = 0;
|
||||
|
||||
if (expectedDirectDownloadFailure)
|
||||
{
|
||||
Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash);
|
||||
}
|
||||
else
|
||||
{
|
||||
failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures);
|
||||
Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
|
||||
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false);
|
||||
|
||||
if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads)
|
||||
{
|
||||
_disableDirectDownloads = true;
|
||||
Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount);
|
||||
}
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
{
|
||||
if (slotAcquired)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
slotAcquired = false;
|
||||
}
|
||||
|
||||
Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash);
|
||||
ClearDownload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (slotAcquired)
|
||||
{
|
||||
_orchestrator.ReleaseDownloadSlot();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(tempFilename);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false);
|
||||
|
||||
Logger.LogDebug("Download end: {id}", objectName);
|
||||
|
||||
ClearDownload();
|
||||
}
|
||||
@@ -511,4 +910,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
_orchestrator.ClearDownloadRequest(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IProgress<long> CreateInlineProgress(Action<long> callback)
|
||||
{
|
||||
return new InlineProgress(callback);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress : IProgress<long>
|
||||
{
|
||||
private readonly Action<long> _callback;
|
||||
|
||||
public InlineProgress(Action<long> callback)
|
||||
{
|
||||
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
}
|
||||
|
||||
public void Report(long value)
|
||||
{
|
||||
_callback(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption, withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct,
|
||||
bool withToken = true) where T : class
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
if (content is not ByteArrayContent)
|
||||
requestMessage.Content = JsonContent.Create(content);
|
||||
else
|
||||
requestMessage.Content = content as ByteArrayContent;
|
||||
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct)
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content,
|
||||
CancellationToken ct, bool withToken = true)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(method, uri);
|
||||
requestMessage.Content = content;
|
||||
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
|
||||
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WaitForDownloadSlotAsync(CancellationToken token)
|
||||
@@ -144,10 +147,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true)
|
||||
{
|
||||
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
if (withToken)
|
||||
{
|
||||
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto.Files;
|
||||
using LightlessSync.API.Routes;
|
||||
using LightlessSync.FileCache;
|
||||
@@ -10,6 +10,8 @@ using LightlessSync.WebAPI.Files.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
|
||||
namespace LightlessSync.WebAPI.Files;
|
||||
|
||||
@@ -19,7 +21,9 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly FileTransferOrchestrator _orchestrator;
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly Dictionary<string, DateTime> _verifiedUploadedHashes = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, DateTime> _verifiedUploadedHashes = new(StringComparer.Ordinal);
|
||||
private readonly object _currentUploadsLock = new();
|
||||
private readonly Dictionary<string, FileTransfer> _currentUploadsByHash = new(StringComparer.Ordinal);
|
||||
private CancellationTokenSource? _uploadCancellationTokenSource = new();
|
||||
|
||||
public FileUploadManager(ILogger<FileUploadManager> logger, LightlessMediator mediator,
|
||||
@@ -40,17 +44,38 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
public List<FileTransfer> CurrentUploads { get; } = [];
|
||||
public bool IsUploading => CurrentUploads.Count > 0;
|
||||
public bool IsUploading
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
return CurrentUploads.Count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<FileTransfer> GetCurrentUploadsSnapshot()
|
||||
{
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
return CurrentUploads.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public bool CancelUpload()
|
||||
{
|
||||
if (CurrentUploads.Any())
|
||||
if (IsUploading)
|
||||
{
|
||||
Logger.LogDebug("Cancelling current upload");
|
||||
_uploadCancellationTokenSource?.Cancel();
|
||||
_uploadCancellationTokenSource?.Dispose();
|
||||
_uploadCancellationTokenSource = null;
|
||||
CurrentUploads.Clear();
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
CurrentUploads.Clear();
|
||||
_currentUploadsByHash.Clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -83,22 +108,44 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
return [.. filesToUpload.Where(f => f.IsForbidden).Select(f => f.Hash)];
|
||||
}
|
||||
|
||||
Task uploadTask = Task.CompletedTask;
|
||||
var cancellationToken = ct ?? CancellationToken.None;
|
||||
var parallelUploads = Math.Clamp(_lightlessConfigService.Current.ParallelUploads, 1, 8);
|
||||
using SemaphoreSlim uploadSlots = new(parallelUploads, parallelUploads);
|
||||
List<Task> uploadTasks = new();
|
||||
|
||||
int i = 1;
|
||||
foreach (var file in filesToUpload)
|
||||
{
|
||||
progress.Report($"Uploading file {i++}/{filesToUpload.Count}. Please wait until the upload is completed.");
|
||||
Logger.LogDebug("[{hash}] Compressing", file);
|
||||
var data = await _fileDbManager.GetCompressedFileData(file.Hash, ct ?? CancellationToken.None).ConfigureAwait(false);
|
||||
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
|
||||
await uploadTask.ConfigureAwait(false);
|
||||
uploadTask = UploadFile(data.Item2, file.Hash, postProgress: false, ct ?? CancellationToken.None);
|
||||
(ct ?? CancellationToken.None).ThrowIfCancellationRequested();
|
||||
uploadTasks.Add(UploadSingleFileAsync(file, uploadSlots, cancellationToken));
|
||||
}
|
||||
|
||||
await uploadTask.ConfigureAwait(false);
|
||||
await Task.WhenAll(uploadTasks).ConfigureAwait(false);
|
||||
|
||||
return [];
|
||||
|
||||
async Task UploadSingleFileAsync(UploadFileDto fileDto, SemaphoreSlim gate, CancellationToken token)
|
||||
{
|
||||
await gate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Logger.LogDebug("[{hash}] Compressing", fileDto.Hash);
|
||||
var data = await _fileDbManager.GetCompressedFileData(fileDto.Hash, token).ConfigureAwait(false);
|
||||
|
||||
var cacheEntry = _fileDbManager.GetFileCacheByHash(data.Item1);
|
||||
if (cacheEntry != null)
|
||||
{
|
||||
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, cacheEntry.ResolvedFilepath);
|
||||
}
|
||||
|
||||
await UploadFile(data.Item2, fileDto.Hash, postProgress: false, token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CharacterData> UploadFiles(CharacterData data, List<UserData> visiblePlayers)
|
||||
@@ -167,7 +214,11 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
_uploadCancellationTokenSource?.Cancel();
|
||||
_uploadCancellationTokenSource?.Dispose();
|
||||
_uploadCancellationTokenSource = null;
|
||||
CurrentUploads.Clear();
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
CurrentUploads.Clear();
|
||||
_currentUploadsByHash.Clear();
|
||||
}
|
||||
_verifiedUploadedHashes.Clear();
|
||||
}
|
||||
|
||||
@@ -211,7 +262,17 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentUploads.Single(f => string.Equals(f.Hash, fileHash, StringComparison.Ordinal)).Transferred = prog.Uploaded;
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
if (_currentUploadsByHash.TryGetValue(fileHash, out var transfer))
|
||||
{
|
||||
transfer.Transferred = prog.Uploaded;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("[{hash}] Could not find upload transfer during progress update", fileHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -240,10 +301,16 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentUploads.Add(new UploadFileTransfer(file)
|
||||
var uploadTransfer = new UploadFileTransfer(file)
|
||||
{
|
||||
Total = new FileInfo(_fileDbManager.GetFileCacheByHash(file.Hash)!.ResolvedFilepath).Length,
|
||||
});
|
||||
};
|
||||
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
CurrentUploads.Add(uploadTransfer);
|
||||
_currentUploadsByHash[file.Hash] = uploadTransfer;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -264,33 +331,75 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
|
||||
_verifiedUploadedHashes[file.Hash] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var totalSize = CurrentUploads.Sum(c => c.Total);
|
||||
long totalSize;
|
||||
List<FileTransfer> pendingUploads;
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
totalSize = CurrentUploads.Sum(c => c.Total);
|
||||
pendingUploads = CurrentUploads.Where(f => f.CanBeTransferred && !f.IsTransferred).ToList();
|
||||
}
|
||||
|
||||
var parallelUploads = Math.Clamp(_lightlessConfigService.Current.ParallelUploads, 1, 8);
|
||||
using SemaphoreSlim uploadSlots = new(parallelUploads, parallelUploads);
|
||||
Logger.LogDebug("Compressing and uploading files");
|
||||
Task uploadTask = Task.CompletedTask;
|
||||
foreach (var file in CurrentUploads.Where(f => f.CanBeTransferred && !f.IsTransferred).ToList())
|
||||
List<Task> uploadTasks = new();
|
||||
|
||||
foreach (var transfer in pendingUploads)
|
||||
{
|
||||
Logger.LogDebug("[{hash}] Compressing", file);
|
||||
var data = await _fileDbManager.GetCompressedFileData(file.Hash, uploadToken).ConfigureAwait(false);
|
||||
CurrentUploads.Single(e => string.Equals(e.Hash, data.Item1, StringComparison.Ordinal)).Total = data.Item2.Length;
|
||||
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
|
||||
await uploadTask.ConfigureAwait(false);
|
||||
uploadTask = UploadFile(data.Item2, file.Hash, true, uploadToken);
|
||||
uploadToken.ThrowIfCancellationRequested();
|
||||
uploadTasks.Add(UploadPendingFileAsync(transfer, uploadSlots, uploadToken));
|
||||
}
|
||||
|
||||
if (CurrentUploads.Any())
|
||||
{
|
||||
await uploadTask.ConfigureAwait(false);
|
||||
await Task.WhenAll(uploadTasks).ConfigureAwait(false);
|
||||
|
||||
var compressedSize = CurrentUploads.Sum(c => c.Total);
|
||||
Logger.LogDebug("Upload complete, compressed {size} to {compressed}", UiSharedService.ByteToString(totalSize), UiSharedService.ByteToString(compressedSize));
|
||||
long compressedSize;
|
||||
HashSet<string> uploadedHashes;
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
compressedSize = CurrentUploads.Sum(c => c.Total);
|
||||
uploadedHashes = CurrentUploads.Select(u => u.Hash).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var file in unverifiedUploadHashes.Where(c => !CurrentUploads.Exists(u => string.Equals(u.Hash, c, StringComparison.Ordinal))))
|
||||
Logger.LogDebug("Upload complete, compressed {size} to {compressed}", UiSharedService.ByteToString(totalSize), UiSharedService.ByteToString(compressedSize));
|
||||
|
||||
foreach (var file in unverifiedUploadHashes.Where(c => !uploadedHashes.Contains(c)))
|
||||
{
|
||||
_verifiedUploadedHashes[file] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
CurrentUploads.Clear();
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
CurrentUploads.Clear();
|
||||
_currentUploadsByHash.Clear();
|
||||
}
|
||||
|
||||
async Task UploadPendingFileAsync(FileTransfer transfer, SemaphoreSlim gate, CancellationToken token)
|
||||
{
|
||||
await gate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Logger.LogDebug("[{hash}] Compressing", transfer.Hash);
|
||||
var data = await _fileDbManager.GetCompressedFileData(transfer.Hash, token).ConfigureAwait(false);
|
||||
lock (_currentUploadsLock)
|
||||
{
|
||||
if (_currentUploadsByHash.TryGetValue(data.Item1, out var trackedUpload))
|
||||
{
|
||||
trackedUpload.Total = data.Item2.Length;
|
||||
}
|
||||
}
|
||||
|
||||
var cacheEntry = _fileDbManager.GetFileCacheByHash(data.Item1);
|
||||
if (cacheEntry != null)
|
||||
{
|
||||
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, cacheEntry.ResolvedFilepath);
|
||||
}
|
||||
|
||||
await UploadFile(data.Item2, transfer.Hash, true, token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public class DownloadFileTransfer : FileTransfer
|
||||
}
|
||||
get => Dto.Size;
|
||||
}
|
||||
public string? DirectDownloadUrl => ((DownloadFileDto)TransferDto).CDNDownloadUrl;
|
||||
|
||||
public long TotalRaw => Dto.RawSize;
|
||||
private DownloadFileDto Dto => (DownloadFileDto)TransferDto;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -34,6 +35,36 @@ public partial class ApiController
|
||||
await _lightlessHub!.SendAsync(nameof(UserAddPair), user).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TryPairWithContentId(string otherCid)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
await _lightlessHub!.SendAsync(nameof(TryPairWithContentId), otherCid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null)
|
||||
{
|
||||
CheckConnection();
|
||||
await _lightlessHub!.InvokeAsync(nameof(SetBroadcastStatus), enabled, groupDto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<BroadcastStatusInfoDto?> IsUserBroadcasting(string hashedCid)
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<BroadcastStatusInfoDto?>(nameof(IsUserBroadcasting), hashedCid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<BroadcastStatusBatchDto> AreUsersBroadcasting(List<string> hashedCids)
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<BroadcastStatusBatchDto>(nameof(AreUsersBroadcasting), hashedCids).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TimeSpan?> GetBroadcastTtl()
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<TimeSpan?>(nameof(GetBroadcastTtl)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UserDelete()
|
||||
{
|
||||
CheckConnection();
|
||||
@@ -53,7 +84,7 @@ public partial class ApiController
|
||||
|
||||
public async Task<UserProfileDto> UserGetProfile(UserDto dto)
|
||||
{
|
||||
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null);
|
||||
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null, BannerPictureBase64: null, Tags: null);
|
||||
return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -103,6 +134,12 @@ public partial class ApiController
|
||||
await _lightlessHub!.InvokeAsync(nameof(UserSetProfile), userDescription).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UserUpdateVanityColors(UserVanityColorsDto dto)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
await _lightlessHub!.InvokeAsync(nameof(UserUpdateVanityColors), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UserUpdateDefaultPermissions(DefaultPermissionsDto defaultPermissionsDto)
|
||||
{
|
||||
CheckConnection();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
@@ -6,6 +6,7 @@ using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -104,10 +105,27 @@ public partial class ApiController
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto)
|
||||
{
|
||||
Logger.LogDebug("Client_ReceiveBroadcastPairRequest: {dto}", dto);
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
ExecuteSafely(() =>
|
||||
{
|
||||
Mediator.Publish(new PairRequestReceivedMessage(dto.myHashedCid, dto.message ?? string.Empty));
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo)
|
||||
{
|
||||
SystemInfoDto = systemInfo;
|
||||
//Mediator.Publish(new UpdateSystemInfoMessage(systemInfo));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -177,7 +195,14 @@ public partial class ApiController
|
||||
public Task Client_UserUpdateProfile(UserDto dto)
|
||||
{
|
||||
Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto);
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileDataMessage(dto.User)));
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileUserDataMessage(dto.User)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
|
||||
{
|
||||
Logger.LogDebug("Client_GroupSendProfile: {dto}", groupInfo);
|
||||
ExecuteSafely(() => Mediator.Publish(new ClearProfileGroupDataMessage(groupInfo.Group)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -277,12 +302,25 @@ public partial class ApiController
|
||||
_lightlessHub!.On(nameof(Client_GroupSendInfo), act);
|
||||
}
|
||||
|
||||
|
||||
public void OnGroupUpdateProfile(Action<GroupProfileDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
_lightlessHub!.On(nameof(Client_GroupSendProfile), act);
|
||||
}
|
||||
|
||||
public void OnReceiveServerMessage(Action<MessageSeverity, string> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
_lightlessHub!.On(nameof(Client_ReceiveServerMessage), act);
|
||||
}
|
||||
|
||||
public void OnReceiveBroadcastPairRequest(Action<UserPairNotificationDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
_lightlessHub!.On(nameof(Client_ReceiveBroadcastPairRequest), act);
|
||||
}
|
||||
|
||||
public void OnUpdateSystemInfo(Action<SystemInfoDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
@@ -349,6 +387,12 @@ public partial class ApiController
|
||||
_lightlessHub!.On(nameof(Client_UserUpdateProfile), act);
|
||||
}
|
||||
|
||||
public void ClientGroupSendProfile(Action<GroupProfileDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
_lightlessHub!.On(nameof(Client_GroupSendProfile), act);
|
||||
}
|
||||
|
||||
public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
@@ -44,6 +45,11 @@ public partial class ApiController
|
||||
CheckConnection();
|
||||
await _lightlessHub!.SendAsync(nameof(GroupClear), group).ConfigureAwait(false);
|
||||
}
|
||||
public async Task GroupClearFinder(GroupDto group)
|
||||
{
|
||||
CheckConnection();
|
||||
await _lightlessHub!.SendAsync(nameof(GroupClearFinder), group).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<GroupJoinDto> GroupCreate()
|
||||
{
|
||||
@@ -80,6 +86,11 @@ public partial class ApiController
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<bool>(nameof(GroupJoinFinalize), passwordedGroup).ConfigureAwait(false);
|
||||
}
|
||||
public async Task<GroupJoinInfoDto> GroupJoinHashed(GroupJoinHashedDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<GroupJoinInfoDto>("GroupJoinHashed", dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task GroupLeave(GroupDto group)
|
||||
{
|
||||
@@ -104,6 +115,18 @@ public partial class ApiController
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false);
|
||||
}
|
||||
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
if (!IsConnected) return new GroupProfileDto(Group: dto.Group, Description: null, Tags: null, PictureBase64: null, IsNsfw: false, BannerBase64: null, IsDisabled: false);
|
||||
return await _lightlessHub!.InvokeAsync<GroupProfileDto>(nameof(GroupGetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task GroupSetProfile(GroupProfileDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
await _lightlessHub!.InvokeAsync(nameof(GroupSetProfile), dto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
|
||||
{
|
||||
@@ -116,6 +139,17 @@ public partial class ApiController
|
||||
CheckConnection();
|
||||
await _lightlessHub!.SendAsync(nameof(GroupUnbanUser), groupPair).ConfigureAwait(false);
|
||||
}
|
||||
public async Task<bool> SetGroupBroadcastStatus(GroupBroadcastRequestDto dto)
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<bool>(nameof(SetGroupBroadcastStatus), dto).ConfigureAwait(false);
|
||||
}
|
||||
public async Task<List<GroupJoinDto>> GetBroadcastedGroups(List<BroadcastStatusInfoDto> broadcastEntries)
|
||||
{
|
||||
CheckConnection();
|
||||
return await _lightlessHub!.InvokeAsync<List<GroupJoinDto>>(nameof(GetBroadcastedGroups), broadcastEntries)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void CheckConnection()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Dalamud.Utility;
|
||||
using Dalamud.Utility;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.API.SignalR;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
@@ -27,6 +29,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly HubFactory _hubFactory;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
@@ -41,12 +44,13 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
private CensusUpdateMessage? _lastCensus;
|
||||
|
||||
public ApiController(ILogger<ApiController> logger, HubFactory hubFactory, DalamudUtilService dalamudUtil,
|
||||
PairManager pairManager, ServerConfigurationManager serverManager, LightlessMediator mediator,
|
||||
TokenProvider tokenProvider, LightlessConfigService lightlessConfigService) : base(logger, mediator)
|
||||
PairManager pairManager, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator,
|
||||
TokenProvider tokenProvider, LightlessConfigService lightlessConfigService, NotificationService lightlessNotificationService) : base(logger, mediator)
|
||||
{
|
||||
_hubFactory = hubFactory;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_pairManager = pairManager;
|
||||
_pairRequestService = pairRequestService;
|
||||
_serverManager = serverManager;
|
||||
_tokenProvider = tokenProvider;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
@@ -76,6 +80,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
public DefaultPermissionsDto? DefaultPermissions => _connectionDto?.DefaultPreferredPermissions ?? null;
|
||||
public string DisplayName => _connectionDto?.User.AliasOrUID ?? string.Empty;
|
||||
|
||||
public bool HasVanity => _connectionDto?.HasVanity ?? false;
|
||||
public string TextColorHex => _connectionDto?.TextColorHex ?? string.Empty;
|
||||
public string TextGlowColorHex => _connectionDto?.TextGlowColorHex ?? string.Empty;
|
||||
|
||||
public bool IsConnected => ServerState == ServerState.Connected;
|
||||
|
||||
public bool IsCurrentVersion => (Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0)) >= (_connectionDto?.CurrentClientVersion ?? new Version(0, 0, 0, 0));
|
||||
@@ -100,6 +108,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
|
||||
public string UID => _connectionDto?.User.UID ?? string.Empty;
|
||||
|
||||
public event Action? OnConnected;
|
||||
|
||||
public async Task<bool> CheckClientHealth()
|
||||
{
|
||||
return await _lightlessHub!.InvokeAsync<bool>(nameof(CheckClientHealth)).ConfigureAwait(false);
|
||||
@@ -230,6 +240,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
_connectionDto = await GetConnectionDto().ConfigureAwait(false);
|
||||
|
||||
ServerState = ServerState.Connected;
|
||||
OnConnected?.Invoke();
|
||||
|
||||
var currentClientVer = Assembly.GetExecutingAssembly().GetName().Version!;
|
||||
|
||||
@@ -420,6 +431,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
Logger.LogDebug("Initializing data");
|
||||
OnDownloadReady((guid) => _ = Client_DownloadReady(guid));
|
||||
OnReceiveServerMessage((sev, msg) => _ = Client_ReceiveServerMessage(sev, msg));
|
||||
OnReceiveBroadcastPairRequest(dto => _ = Client_ReceiveBroadcastPairRequest(dto));
|
||||
OnUpdateSystemInfo((dto) => _ = Client_UpdateSystemInfo(dto));
|
||||
|
||||
OnUserSendOffline((dto) => _ = Client_UserSendOffline(dto));
|
||||
@@ -441,6 +453,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
OnGroupPairLeft((dto) => _ = Client_GroupPairLeft(dto));
|
||||
OnGroupSendFullInfo((dto) => _ = Client_GroupSendFullInfo(dto));
|
||||
OnGroupSendInfo((dto) => _ = Client_GroupSendInfo(dto));
|
||||
OnGroupUpdateProfile((dto) => _ = Client_GroupSendProfile(dto));
|
||||
OnGroupChangeUserPairPermissions((dto) => _ = Client_GroupChangeUserPairPermissions(dto));
|
||||
|
||||
OnGposeLobbyJoin((dto) => _ = Client_GposeLobbyJoin(dto));
|
||||
@@ -517,6 +530,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
return;
|
||||
}
|
||||
ServerState = ServerState.Connected;
|
||||
OnConnected?.Invoke();
|
||||
await LoadIninitialPairsAsync().ConfigureAwait(false);
|
||||
await LoadOnlinePairsAsync().ConfigureAwait(false);
|
||||
Mediator.Publish(new ConnectedMessage(_connectionDto));
|
||||
@@ -592,5 +606,45 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
|
||||
ServerState = state;
|
||||
}
|
||||
|
||||
public Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task UpdateChatPresence(ChatPresenceUpdateDto presence)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
#pragma warning restore MA0040
|
||||
@@ -70,13 +70,6 @@ public class HubFactory : MediatorSubscriberBase
|
||||
_ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling
|
||||
};
|
||||
|
||||
if (_isWine && !_serverConfigurationManager.CurrentServer.ForceWebSockets
|
||||
&& transportType.HasFlag(HttpTransportType.WebSockets))
|
||||
{
|
||||
Logger.LogDebug("Wine detected, falling back to ServerSentEvents / LongPolling");
|
||||
transportType = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Building new HubConnection using transport {transport}", transportType);
|
||||
|
||||
_instance = new HubConnectionBuilder()
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
"Microsoft.IdentityModel.Tokens": "8.7.0"
|
||||
}
|
||||
},
|
||||
"YamlDotNet": {
|
||||
"type": "Direct",
|
||||
"requested": "[16.3.0, )",
|
||||
"resolved": "16.3.0",
|
||||
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
|
||||
},
|
||||
"K4os.Compression.LZ4": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.3.8",
|
||||
|
||||
Submodule PenumbraAPI updated: dd14131793...a2f8923546
Reference in New Issue
Block a user