diff --git a/LightlessAPI b/LightlessAPI index 44fbe10..0bc7abb 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 44fbe1045872fcae4df45e43625a9ff1a79bc2ef +Subproject commit 0bc7abb274548bcde36c65ef1cf9f1a143d6492c diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index ed57656..972c4d9 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService private readonly Lock _fileWriteLock = new(); private readonly IpcManager _ipcManager; private readonly ILogger _logger; + private bool _csvHeaderEnsured; public string CacheFolder => _configService.Current.CacheFolder; public FileCacheManager(ILogger logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) @@ -462,6 +463,7 @@ public sealed class FileCacheManager : IHostedService string[] existingLines = File.ReadAllLines(_csvPath); if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion) { + _csvHeaderEnsured = true; return; } @@ -481,6 +483,18 @@ public sealed class FileCacheManager : IHostedService } File.WriteAllText(_csvPath, rebuilt.ToString()); + _csvHeaderEnsured = true; + } + + private void EnsureCsvHeaderLockedCached() + { + if (_csvHeaderEnsured) + { + return; + } + + EnsureCsvHeaderLocked(); + _csvHeaderEnsured = true; } private void BackupUnsupportedCache(string suffix) @@ -540,10 +554,11 @@ public sealed class FileCacheManager : IHostedService if (!File.Exists(_csvPath)) { File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); + _csvHeaderEnsured = true; } else { - EnsureCsvHeaderLocked(); + EnsureCsvHeaderLockedCached(); File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); } } diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 737c1f0..1a35ad6 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -2,25 +2,33 @@ using LightlessSync.Services; using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; namespace LightlessSync.FileCache; -public sealed class FileCompactor +public sealed class FileCompactor : IDisposable { public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const ulong WOF_PROVIDER_FILE = 2UL; private readonly Dictionary _clusterSizes; - + private readonly ConcurrentDictionary _pendingCompactions; private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo; private readonly ILogger _logger; private readonly LightlessConfigService _lightlessConfigService; private readonly DalamudUtilService _dalamudUtilService; + private readonly Channel _compactionQueue; + private readonly CancellationTokenSource _compactionCts = new(); + private readonly Task _compactionWorker; public FileCompactor(ILogger logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) { _clusterSizes = new(StringComparer.Ordinal); + _pendingCompactions = new(StringComparer.OrdinalIgnoreCase); _logger = logger; _lightlessConfigService = lightlessConfigService; _dalamudUtilService = dalamudUtilService; @@ -29,6 +37,18 @@ public sealed class FileCompactor Algorithm = CompressionAlgorithm.XPRESS8K, Flags = 0 }; + + _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + _compactionWorker = Task.Factory.StartNew( + () => ProcessQueueAsync(_compactionCts.Token), + _compactionCts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .Unwrap(); } private enum CompressionAlgorithm @@ -87,7 +107,30 @@ public sealed class FileCompactor return; } - CompactFile(filePath); + EnqueueCompaction(filePath); + } + + public void Dispose() + { + _compactionQueue.Writer.TryComplete(); + _compactionCts.Cancel(); + try + { + if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5))) + { + _logger.LogDebug("Compaction worker did not shut down within timeout"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Error shutting down compaction worker"); + } + finally + { + _compactionCts.Dispose(); + } + + GC.SuppressFinalize(this); } [DllImport("kernel32.dll")] @@ -226,4 +269,67 @@ public sealed class FileCompactor public CompressionAlgorithm Algorithm; public ulong Flags; } -} \ No newline at end of file + + private void EnqueueCompaction(string filePath) + { + if (!_pendingCompactions.TryAdd(filePath, 0)) + { + return; + } + + if (!_compactionQueue.Writer.TryWrite(filePath)) + { + _pendingCompactions.TryRemove(filePath, out _); + _logger.LogDebug("Failed to enqueue compaction job for {file}", filePath); + } + } + + private async Task ProcessQueueAsync(CancellationToken token) + { + try + { + while (await _compactionQueue.Reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + while (_compactionQueue.Reader.TryRead(out var filePath)) + { + try + { + if (token.IsCancellationRequested) + { + return; + } + + if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor) + { + continue; + } + + if (!File.Exists(filePath)) + { + _logger.LogTrace("Skipping compaction for missing file {file}", filePath); + continue; + } + + CompactFile(filePath); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error compacting file {file}", filePath); + } + finally + { + _pendingCompactions.TryRemove(filePath, out _); + } + } + } + } + catch (OperationCanceledException) + { + // expected during shutdown + } + } +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 4d82529..203db7d 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -67,6 +67,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowUploading { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true; + public bool EnableDirectDownloads { get; set; } = true; public int TimeSpanBetweenScansInSeconds { get; set; } = 30; public int TransferBarsHeight { get; set; } = 12; public bool TransferBarsShowText { get; set; } = true; @@ -86,6 +87,7 @@ public class LightlessConfig : ILightlessConfiguration public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi; public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi; public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay; + public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi; // Basic Settings public float NotificationOpacity { get; set; } = 0.95f; @@ -95,6 +97,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowNotificationTimestamp { get; set; } = false; // Position & Layout + public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right; public int NotificationOffsetY { get; set; } = 50; public int NotificationOffsetX { get; set; } = 0; public float NotificationWidth { get; set; } = 350f; @@ -102,6 +105,7 @@ public class LightlessConfig : ILightlessConfiguration // Animation & Effects public float NotificationAnimationSpeed { get; set; } = 10f; + public float NotificationSlideSpeed { get; set; } = 10f; public float NotificationAccentBarWidth { get; set; } = 3f; // Duration per Type @@ -110,16 +114,19 @@ public class LightlessConfig : ILightlessConfiguration public int ErrorNotificationDurationSeconds { get; set; } = 20; public int PairRequestDurationSeconds { get; set; } = 180; public int DownloadNotificationDurationSeconds { get; set; } = 300; + public int PerformanceNotificationDurationSeconds { get; set; } = 20; public uint CustomInfoSoundId { get; set; } = 2; // Se2 public uint CustomWarningSoundId { get; set; } = 16; // Se15 public uint CustomErrorSoundId { get; set; } = 16; // Se15 public uint PairRequestSoundId { get; set; } = 5; // Se5 - public uint DownloadSoundId { get; set; } = 15; // Se14 + public uint PerformanceSoundId { get; set; } = 16; // Se15 public bool DisableInfoSound { get; set; } = true; public bool DisableWarningSound { get; set; } = true; public bool DisableErrorSound { get; set; } = true; public bool DisablePairRequestSound { get; set; } = true; - public bool DisableDownloadSound { get; set; } = true; + public bool DisablePerformanceSound { get; set; } = true; + public bool ShowPerformanceNotificationActions { get; set; } = true; + public bool ShowPairRequestNotificationActions { get; set; } = true; public bool UseFocusTarget { get; set; } = false; public bool overrideFriendColor { get; set; } = false; public bool overridePartyColor { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs b/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs index 2815986..c0609c6 100644 --- a/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs +++ b/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs @@ -17,5 +17,12 @@ public enum NotificationType Warning, Error, PairRequest, - Download + Download, + Performance +} + +public enum NotificationCorner +{ + Right, + Left } \ No newline at end of file diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 4bab148..5b31c88 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.2 + 1.12.3 https://github.com/Light-Public-Syncshells/LightlessClient diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index eea3ea6..231ded3 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -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(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor); + return new FileDownloadManager( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _fileTransferOrchestrator, + _fileCacheManager, + _fileCompactor, + _pairProcessingLimiter, + _configService); } -} \ No newline at end of file +} diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 6e21ad2..f752051 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -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 CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct) diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index b7da8bc..8a724b4 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -48,20 +48,23 @@ 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 DownloadStatus) : MessageBase; public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase; diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index dc761bb..a28be5f 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -208,7 +208,13 @@ public unsafe class NameplateHandler : IMediatorSubscriber for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) { - var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value; + if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue; + + var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i]; + + if (objectInfoPtr == null) continue; + + var objectInfo = objectInfoPtr.Value; if (objectInfo == null || objectInfo->GameObject == null) continue; diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 3f3fdfb..755e756 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -10,9 +10,11 @@ using LightlessSync.UI.Models; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using FFXIVClientStructs.FFXIV.Client.UI; +using LightlessSync.API.Data; using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType; namespace LightlessSync.Services; + public class NotificationService : DisposableMediatorSubscriberBase, IHostedService { private readonly ILogger _logger; @@ -44,6 +46,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ { Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandlePairRequestsUpdated); + Mediator.Subscribe(this, HandlePerformanceNotification); return Task.CompletedTask; } @@ -107,23 +110,42 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) { - var notification = new LightlessNotification + var location = GetNotificationLocation(NotificationType.PairRequest); + + // Show in chat if configured + if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi) { - Id = $"pair_request_{senderId}", - Title = "Pair Request Received", - Message = $"{senderName} wants to directly pair with you.", - Type = NotificationType.PairRequest, - Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), - SoundEffectId = GetPairRequestSoundId(), - Actions = CreatePairRequestActions(onAccept, onDecline) - }; - - if (notification.SoundEffectId.HasValue) - { - PlayNotificationSound(notification.SoundEffectId.Value); + ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest)); } + + // Show Lightless notification if configured and action buttons are enabled + if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi) + && _configService.Current.UseLightlessNotifications + && _configService.Current.ShowPairRequestNotificationActions) + { + var notification = new LightlessNotification + { + Id = $"pair_request_{senderId}", + Title = "Pair Request Received", + Message = $"{senderName} wants to directly pair with you.", + Type = NotificationType.PairRequest, + Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), + SoundEffectId = GetPairRequestSoundId(), + Actions = CreatePairRequestActions(onAccept, onDecline) + }; - Mediator.Publish(new LightlessNotificationMessage(notification)); + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat) + { + // Fall back to regular notification without action buttons + HandleNotificationMessage(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest)); + } } private uint? GetPairRequestSoundId() => @@ -356,6 +378,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), + NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds), _ => TimeSpan.FromSeconds(10) }; @@ -371,7 +394,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ NotificationType.Info => _configService.Current.DisableInfoSound, NotificationType.Warning => _configService.Current.DisableWarningSound, NotificationType.Error => _configService.Current.DisableErrorSound, - NotificationType.Download => _configService.Current.DisableDownloadSound, + NotificationType.Performance => _configService.Current.DisablePerformanceSound, + NotificationType.Download => true, // Download sounds always disabled _ => false }; @@ -380,7 +404,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId, - NotificationType.Download => _configService.Current.DownloadSoundId, + NotificationType.Performance => _configService.Current.PerformanceSoundId, _ => NotificationSounds.GetDefaultSound(type) }; @@ -418,6 +442,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ NotificationType.Error => _configService.Current.LightlessErrorNotification, NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, NotificationType.Download => _configService.Current.LightlessDownloadNotification, + NotificationType.Performance => _configService.Current.LightlessPerformanceNotification, _ => NotificationLocation.LightlessUi }; @@ -505,6 +530,18 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ case NotificationType.Error: PrintErrorChat(msg.Message); break; + + case NotificationType.PairRequest: + PrintPairRequestChat(msg.Title, msg.Message); + break; + + case NotificationType.Performance: + PrintPerformanceChat(msg.Title, msg.Message); + break; + + // Download notifications don't support chat output, will be a giga spam otherwise + case NotificationType.Download: + break; } } @@ -528,6 +565,22 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ _chatGui.Print(se.BuiltString); } + private void PrintPairRequestChat(string? title, string? message) + { + SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") + .AddUiForeground("Pair Request: ", 541).AddUiForegroundOff() + .AddText(title ?? message ?? string.Empty); + _chatGui.Print(se.BuiltString); + } + + private void PrintPerformanceChat(string? title, string? message) + { + SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") + .AddUiForeground("Performance: ", 508).AddUiForegroundOff() + .AddText(title ?? message ?? string.Empty); + _chatGui.Print(se.BuiltString); + } + private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) { var activeRequests = _pairRequestService.GetActiveRequests(); @@ -557,5 +610,144 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ ); } } -} - \ No newline at end of file + + 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 CreatePerformanceActions(UserData userData, bool isPaused, string playerName) + { + var actions = new List(); + + 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})"; + } +} \ No newline at end of file diff --git a/LightlessSync/Services/PairProcessingLimiter.cs b/LightlessSync/Services/PairProcessingLimiter.cs index 0e75d28..239ba75 100644 --- a/LightlessSync/Services/PairProcessingLimiter.cs +++ b/LightlessSync/Services/PairProcessingLimiter.cs @@ -15,6 +15,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase private readonly SemaphoreSlim _semaphore; private int _currentLimit; private int _pendingReductions; + private int _pendingIncrements; private int _waiting; private int _inFlight; @@ -70,7 +71,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase if (!IsEnabled) { - _semaphore.Release(); + TryReleaseSemaphore(); return NoopReleaser.Instance; } @@ -90,18 +91,12 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase var releaseAmount = HardLimit - _semaphore.CurrentCount; if (releaseAmount > 0) { - try - { - _semaphore.Release(releaseAmount); - } - catch (SemaphoreFullException) - { - // ignore, already at max - } + TryReleaseSemaphore(releaseAmount); } _currentLimit = desiredLimit; _pendingReductions = 0; + _pendingIncrements = 0; return; } @@ -113,10 +108,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase if (desiredLimit > _currentLimit) { var increment = desiredLimit - _currentLimit; - var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount); - if (allowed > 0) + _pendingIncrements += increment; + + var available = HardLimit - _semaphore.CurrentCount; + var toRelease = Math.Min(_pendingIncrements, available); + if (toRelease > 0 && TryReleaseSemaphore(toRelease)) { - _semaphore.Release(allowed); + _pendingIncrements -= toRelease; } } else @@ -133,6 +131,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase { _pendingReductions += remaining; } + + if (_pendingIncrements > 0) + { + var offset = Math.Min(_pendingIncrements, _pendingReductions); + _pendingIncrements -= offset; + _pendingReductions -= offset; + } } _currentLimit = desiredLimit; @@ -146,6 +151,25 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase return Math.Clamp(configured, 1, HardLimit); } + private bool TryReleaseSemaphore(int count = 1) + { + if (count <= 0) + { + return true; + } + + try + { + _semaphore.Release(count); + return true; + } + catch (SemaphoreFullException ex) + { + Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count); + return false; + } + } + private void ReleaseOne() { var inFlight = Interlocked.Decrement(ref _inFlight); @@ -166,9 +190,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase _pendingReductions--; return; } + + if (_pendingIncrements > 0) + { + if (!TryReleaseSemaphore()) + { + return; + } + + _pendingIncrements--; + return; + } } - _semaphore.Release(); + TryReleaseSemaphore(); } protected override void Dispose(bool disposing) diff --git a/LightlessSync/Services/PlayerPerformanceService.cs b/LightlessSync/Services/PlayerPerformanceService.cs index ef43849..7db92e1 100644 --- a/LightlessSync/Services/PlayerPerformanceService.cs +++ b/LightlessSync/Services/PlayerPerformanceService.cs @@ -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)); diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 301f177..1b1ec16 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -24,6 +24,7 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly ConcurrentDictionary _uploadingPlayers = new(); private readonly NotificationService _notificationService; private bool _notificationDismissed = true; + private int _lastDownloadStateHash = 0; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, @@ -65,7 +66,7 @@ public class DownloadUi : WindowMediatorSubscriberBase _currentDownloads.TryRemove(msg.DownloadId, out _); if (!_currentDownloads.Any()) { - _notificationService.DismissPairDownloadNotification(); + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); } }); Mediator.Subscribe(this, (_) => IsOpen = false); @@ -116,7 +117,7 @@ public class DownloadUi : WindowMediatorSubscriberBase } catch { - // ignore errors thrown from UI + _logger.LogDebug("Error drawing upload progress"); } try @@ -131,17 +132,19 @@ public class DownloadUi : WindowMediatorSubscriberBase // Use notification system if (_currentDownloads.Any()) { - UpdateDownloadNotification(limiterSnapshot); + UpdateDownloadNotificationIfChanged(limiterSnapshot); _notificationDismissed = false; } else if (!_notificationDismissed) { - _notificationService.DismissPairDownloadNotification(); + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); _notificationDismissed = true; + _lastDownloadStateHash = 0; } } else { + // Use text overlay if (limiterSnapshot.IsEnabled) { var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; @@ -183,7 +186,7 @@ public class DownloadUi : WindowMediatorSubscriberBase } catch { - // ignore errors thrown from UI + _logger.LogDebug("Error drawing download progress"); } } @@ -255,7 +258,7 @@ public class DownloadUi : WindowMediatorSubscriberBase } catch { - // ignore errors thrown on UI + _logger.LogDebug("Error drawing upload progress"); } } } @@ -298,20 +301,34 @@ public class DownloadUi : WindowMediatorSubscriberBase }; } - private void UpdateDownloadNotification(PairProcessingLimiterSnapshot limiterSnapshot) + private void UpdateDownloadNotificationIfChanged(PairProcessingLimiterSnapshot limiterSnapshot) { - var downloadStatus = new List<(string playerName, float progress, string status)>(); + var downloadStatus = new List<(string playerName, float progress, string status)>(_currentDownloads.Count); + var hashCode = new HashCode(); - foreach (var item in _currentDownloads.ToList()) + foreach (var item in _currentDownloads) { - var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot); - var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); - var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); - var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); - var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); - var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); - var totalBytes = item.Value.Sum(c => c.Value.TotalBytes); - var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); + var dlSlot = 0; + var dlQueue = 0; + var dlProg = 0; + var dlDecomp = 0; + long totalBytes = 0; + long transferredBytes = 0; + + // Single pass through the dictionary to count everything - avoid multiple LINQ iterations + foreach (var entry in item.Value) + { + var fileStatus = entry.Value; + switch (fileStatus.DownloadStatus) + { + case DownloadStatus.WaitingForSlot: dlSlot++; break; + case DownloadStatus.WaitingForQueue: dlQueue++; break; + case DownloadStatus.Downloading: dlProg++; break; + case DownloadStatus.Decompressing: dlDecomp++; break; + } + totalBytes += fileStatus.TotalBytes; + transferredBytes += fileStatus.TransferredBytes; + } var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f; @@ -323,13 +340,27 @@ public class DownloadUi : WindowMediatorSubscriberBase else status = "completed"; downloadStatus.Add((item.Key.Name, progress, status)); + + // Build hash from meaningful state + hashCode.Add(item.Key.Name); + hashCode.Add(transferredBytes); + hashCode.Add(totalBytes); + hashCode.Add(status); } - // Pass queue waiting count separately, show notification if there are downloads or queue items var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0; - if (downloadStatus.Any() || queueWaiting > 0) + hashCode.Add(queueWaiting); + + var currentHash = hashCode.ToHashCode(); + + // Only update notification if state has actually changed + if (currentHash != _lastDownloadStateHash) { - _notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting); + _lastDownloadStateHash = currentHash; + if (downloadStatus.Count > 0 || queueWaiting > 0) + { + _notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting); + } } } diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index c0d5b01..2ac26b7 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -22,10 +22,16 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase 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 _notifications = new(); private readonly object _notificationLock = new(); private readonly LightlessConfigService _configService; + private readonly Dictionary _notificationYOffsets = new(); + private readonly Dictionary _notificationTargetYOffsets = new(); public LightlessNotificationUI(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) @@ -49,12 +55,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandleNotificationDismissMessage); + Mediator.Subscribe(this, HandleClearAllNotifications); } - private void HandleNotificationMessage(LightlessNotificationMessage message) => - AddNotification(message.Notification); - - private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => - RemoveNotification(message.NotificationId); + private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification); + private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId); + private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications(); public void AddNotification(LightlessNotification notification) { @@ -96,20 +101,34 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase } } + public void ClearAllNotifications() + { + lock (_notificationLock) + { + foreach (var notification in _notifications) + { + StartOutAnimation(notification); + } + } + } + private void StartOutAnimation(LightlessNotification notification) { notification.IsAnimatingOut = true; notification.IsAnimatingIn = false; } + + private bool ShouldRemoveNotification(LightlessNotification notification) => + notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; protected override void DrawInternal() { ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); - + lock (_notificationLock) { UpdateNotifications(); - + if (_notifications.Count == 0) { ImGui.PopStyleVar(); @@ -118,33 +137,45 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase } var viewport = ImGui.GetMainViewport(); + + // Window auto-resizes based on content (AlwaysAutoResize flag) Position = CalculateWindowPosition(viewport); + PositionCondition = ImGuiCond.Always; + DrawAllNotifications(); } - + ImGui.PopStyleVar(); } private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport) { - var x = viewport.WorkPos.X + viewport.WorkSize.X - - _configService.Current.NotificationWidth - - _configService.Current.NotificationOffsetX - - WindowPaddingOffset; - var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY; - return new Vector2(x, y); + var corner = _configService.Current.NotificationCorner; + var offsetX = _configService.Current.NotificationOffsetX; + var width = _configService.Current.NotificationWidth; + + float posX = corner == NotificationCorner.Left + ? viewport.WorkPos.X + offsetX - WindowPaddingOffset + : viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - WindowPaddingOffset; + + return new Vector2(posX, viewport.WorkPos.Y); } private void DrawAllNotifications() { + var offsetY = _configService.Current.NotificationOffsetY; + var startY = ImGui.GetCursorPosY() + offsetY; + for (int i = 0; i < _notifications.Count; i++) { - DrawNotification(_notifications[i], i); + var notification = _notifications[i]; - if (i < _notifications.Count - 1) + if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset)) { - ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing)); + ImGui.SetCursorPosY(startY + yOffset); } + + DrawNotification(notification, i); } } @@ -174,18 +205,65 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private void UpdateAnimationsAndRemoveExpired(float deltaTime) { + UpdateTargetYPositions(); + for (int i = _notifications.Count - 1; i >= 0; i--) { var notification = _notifications[i]; UpdateNotificationAnimation(notification, deltaTime); + UpdateNotificationYOffset(notification, deltaTime); if (ShouldRemoveNotification(notification)) { _notifications.RemoveAt(i); + _notificationYOffsets.Remove(notification.Id); + _notificationTargetYOffsets.Remove(notification.Id); } } } + private void UpdateTargetYPositions() + { + float currentY = 0f; + + for (int i = 0; i < _notifications.Count; i++) + { + var notification = _notifications[i]; + + if (!_notificationTargetYOffsets.ContainsKey(notification.Id)) + { + _notificationTargetYOffsets[notification.Id] = currentY; + _notificationYOffsets[notification.Id] = currentY; + } + else + { + _notificationTargetYOffsets[notification.Id] = currentY; + } + + currentY += CalculateNotificationHeight(notification) + _configService.Current.NotificationSpacing; + } + } + + private void UpdateNotificationYOffset(LightlessNotification notification, float deltaTime) + { + if (!_notificationYOffsets.ContainsKey(notification.Id) || !_notificationTargetYOffsets.ContainsKey(notification.Id)) + return; + + var current = _notificationYOffsets[notification.Id]; + var target = _notificationTargetYOffsets[notification.Id]; + var diff = target - current; + + if (Math.Abs(diff) < 0.5f) + { + _notificationYOffsets[notification.Id] = target; + } + else + { + var speed = _configService.Current.NotificationSlideSpeed; + _notificationYOffsets[notification.Id] = current + (diff * deltaTime * speed); + } + } + private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime) { if (notification.IsAnimatingIn && notification.AnimationProgress < 1f) @@ -209,20 +287,24 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase } } - private bool ShouldRemoveNotification(LightlessNotification notification) => - notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; + private Vector2 CalculateSlideOffset(float alpha) + { + var distance = (1f - alpha) * SlideAnimationDistance; + var corner = _configService.Current.NotificationCorner; + return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0); + } private void DrawNotification(LightlessNotification notification, int index) { var alpha = notification.AnimationProgress; if (alpha <= 0f) return; - var slideOffset = (1f - alpha) * SlideAnimationDistance; + var slideOffset = CalculateSlideOffset(alpha); var originalCursorPos = ImGui.GetCursorPos(); - ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); + ImGui.SetCursorPos(originalCursorPos + slideOffset); var notificationHeight = CalculateNotificationHeight(notification); - var notificationWidth = _configService.Current.NotificationWidth - slideOffset; + var notificationWidth = _configService.Current.NotificationWidth; ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); @@ -308,15 +390,28 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor) { var accentWidth = _configService.Current.NotificationAccentBarWidth; - if (accentWidth > 0f) + if (accentWidth <= 0f) return; + + var corner = _configService.Current.NotificationCorner; + Vector2 accentStart, accentEnd; + + if (corner == NotificationCorner.Left) { - drawList.AddRectFilled( - windowPos, - windowPos + new Vector2(accentWidth, windowSize.Y), - ImGui.ColorConvertFloat4ToU32(accentColor), - 3f - ); + accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0); + accentEnd = windowPos + windowSize; } + else + { + accentStart = windowPos; + accentEnd = windowPos + new Vector2(accentWidth, windowSize.Y); + } + + drawList.AddRectFilled( + accentStart, + accentEnd, + ImGui.ColorConvertFloat4ToU32(accentColor), + 3f + ); } private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) @@ -371,81 +466,112 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private void DrawNotificationText(LightlessNotification notification, float alpha) { - var padding = new Vector2(10f, 6f); - var contentPos = new Vector2(padding.X, padding.Y); + var contentPos = new Vector2(ContentPaddingX, ContentPaddingY); var windowSize = ImGui.GetWindowSize(); - var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2); + var contentWidth = CalculateContentWidth(windowSize.X); ImGui.SetCursorPos(contentPos); - var titleHeight = DrawTitle(notification, contentSize.X, alpha); - DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha); + var titleHeight = DrawTitle(notification, contentWidth, alpha); + DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha); - if (notification.Actions.Count > 0) + if (HasActions(notification)) { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y); - ImGui.SetCursorPosX(contentPos.X); - DrawNotificationActions(notification, contentSize.X, alpha); + PositionActionsAtBottom(windowSize.Y); + DrawNotificationActions(notification, contentWidth, alpha); } } + private float CalculateContentWidth(float windowWidth) => + windowWidth - (ContentPaddingX * 2); + + private bool HasActions(LightlessNotification notification) => + notification.Actions.Count > 0; + + private void PositionActionsAtBottom(float windowHeight) + { + var actionHeight = ImGui.GetFrameHeight(); + var bottomY = windowHeight - ContentPaddingY - actionHeight; + ImGui.SetCursorPosY(bottomY); + ImGui.SetCursorPosX(ContentPaddingX); + } + private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha) { - using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) + var titleColor = new Vector4(1f, 1f, 1f, alpha); + var titleText = FormatTitleText(notification); + + using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) { - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth); - var titleStartY = ImGui.GetCursorPosY(); - - var titleText = _configService.Current.ShowNotificationTimestamp - ? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}" - : notification.Title; - - ImGui.TextWrapped(titleText); - var titleHeight = ImGui.GetCursorPosY() - titleStartY; - ImGui.PopTextWrapPos(); - return titleHeight; + return DrawWrappedText(titleText, contentWidth); } } + private string FormatTitleText(LightlessNotification notification) + { + if (!_configService.Current.ShowNotificationTimestamp) + return notification.Title; + + var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); + return $"[{timestamp}] {notification.Title}"; + } + + private float DrawWrappedText(string text, float wrapWidth) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth); + var startY = ImGui.GetCursorPosY(); + ImGui.TextWrapped(text); + var height = ImGui.GetCursorPosY() - startY; + ImGui.PopTextWrapPos(); + return height; + } + private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha) { if (string.IsNullOrEmpty(notification.Message)) return; - ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f)); - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth); - using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha))) + var messagePos = contentPos + new Vector2(0f, titleHeight + TitleMessageSpacing); + var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha); + + ImGui.SetCursorPos(messagePos); + + using (ImRaii.PushColor(ImGuiCol.Text, messageColor)) { - ImGui.TextWrapped(notification.Message); + DrawWrappedText(notification.Message, contentWidth); } - ImGui.PopTextWrapPos(); } private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha) { - var buttonSpacing = 8f; - var rightPadding = 10f; - var usableWidth = availableWidth - rightPadding; - var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing; - var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count; + var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth); _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", notification.Actions.Count, buttonWidth, availableWidth); - var startCursorPos = ImGui.GetCursorPos(); + var startX = ImGui.GetCursorPosX(); for (int i = 0; i < notification.Actions.Count; i++) { - var action = notification.Actions[i]; - if (i > 0) { ImGui.SameLine(); - var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing); - ImGui.SetCursorPosX(currentX); + PositionActionButton(i, startX, buttonWidth); } - DrawActionButton(action, notification, alpha, buttonWidth); + DrawActionButton(notification.Actions[i], notification, alpha, buttonWidth); } } + + private float CalculateActionButtonWidth(int actionCount, float availableWidth) + { + var totalSpacing = (actionCount - 1) * ActionButtonSpacing; + return (availableWidth - totalSpacing) / actionCount; + } + + private void PositionActionButton(int index, float startX, float buttonWidth) + { + var xPosition = startX + index * (buttonWidth + ActionButtonSpacing); + ImGui.SetCursorPosX(xPosition); + } private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth) { @@ -543,7 +669,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private float CalculateNotificationHeight(LightlessNotification notification) { - var contentWidth = _configService.Current.NotificationWidth - 35f; + var contentWidth = CalculateContentWidth(_configService.Current.NotificationWidth); var height = 12f; height += CalculateTitleHeight(notification, contentWidth); @@ -590,6 +716,8 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase NotificationType.Error => UIColors.Get("DimRed"), NotificationType.PairRequest => UIColors.Get("LightlessBlue"), NotificationType.Download => UIColors.Get("LightlessGreen"), + NotificationType.Performance => UIColors.Get("LightlessOrange"), + _ => UIColors.Get("LightlessPurple") }; } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index dfb6cdb..d04ee2c 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -591,6 +591,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter; bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload; int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes; + bool enableDirectDownloads = _configService.Current.EnableDirectDownloads; ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("Global Download Speed Limit"); @@ -622,6 +623,13 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("0 = No limit/infinite"); + if (ImGui.Checkbox("[BETA] Enable Lightspeed Downloads", ref enableDirectDownloads)) + { + _configService.Current.EnableDirectDownloads = enableDirectDownloads; + _configService.Save(); + } + _uiShared.DrawHelpText("Uses signed CDN links when available. Disable to force the legacy queued download flow."); + if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10)) { _configService.Current.ParallelDownloads = maxParallelDownloads; @@ -1990,7 +1998,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), ("LightlessGreen", "Success Green", "Join buttons and success messages"), ("LightlessYellow", "Warning Yellow", "Warning colors"), - ("LightlessYellow2", "Warning Yellow (Alt)", "Warning colors"), + ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), ("DimRed", "Error Red", "Error and offline colors") }; @@ -3493,69 +3501,192 @@ public class SettingsUi : WindowMediatorSubscriberBase if (useLightlessNotifications) { // Lightless notification locations - ImGui.Indent(); - var lightlessLocations = GetLightlessNotificationLocations(); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Info Notifications:"); - ImGui.SameLine(); - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); - _uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) => - { - _configService.Current.LightlessInfoNotification = location; - _configService.Save(); - }, _configService.Current.LightlessInfoNotification); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Warning Notifications:"); - ImGui.SameLine(); - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); - _uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel, - (location) => - { - _configService.Current.LightlessWarningNotification = location; - _configService.Save(); - }, _configService.Current.LightlessWarningNotification); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Error Notifications:"); - ImGui.SameLine(); - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); - _uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) => - { - _configService.Current.LightlessErrorNotification = location; - _configService.Save(); - }, _configService.Current.LightlessErrorNotification); - - ImGuiHelpers.ScaledDummy(3); - _uiShared.DrawHelpText("Special notification types:"); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Pair Request Notifications:"); - ImGui.SameLine(); - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); - _uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel, - (location) => - { - _configService.Current.LightlessPairRequestNotification = location; - _configService.Save(); - }, _configService.Current.LightlessPairRequestNotification); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Download Progress Notifications:"); - ImGui.SameLine(); - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); var downloadLocations = GetDownloadNotificationLocations(); - _uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel, - (location) => + + if (ImGui.BeginTable("##NotificationLocationTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) + { + ImGui.TableSetupColumn("Notification Type", ImGuiTableColumnFlags.WidthFixed, 200f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Location", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Test", ImGuiTableColumnFlags.WidthFixed, 40f * ImGuiHelpers.GlobalScale); + ImGui.TableHeadersRow(); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Info Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) => { - _configService.Current.LightlessDownloadNotification = location; + _configService.Current.LightlessInfoNotification = location; _configService.Save(); - }, _configService.Current.LightlessDownloadNotification); + }, _configService.Current.LightlessInfoNotification); + ImGui.TableSetColumnIndex(2); + var availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_info", new Vector2(availableWidth, 0))) + { + Mediator.Publish(new NotificationMessage("Test Info", + "This is a test info notification to let you know Chocola is cute :3", NotificationType.Info)); + } + } + UiSharedService.AttachToolTip("Test info notification"); + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Warning Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessWarningNotification = location; + _configService.Save(); + }, _configService.Current.LightlessWarningNotification); + ImGui.TableSetColumnIndex(2); + availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_warning", new Vector2(availableWidth, 0))) + { + Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!", + NotificationType.Warning)); + } + } + UiSharedService.AttachToolTip("Test warning notification"); - ImGui.Unindent(); + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Error Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.LightlessErrorNotification = location; + _configService.Save(); + }, _configService.Current.LightlessErrorNotification); + ImGui.TableSetColumnIndex(2); + availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_error", new Vector2(availableWidth, 0))) + { + Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!", + NotificationType.Error)); + } + } + UiSharedService.AttachToolTip("Test error notification"); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Pair Request Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessPairRequestNotification = location; + _configService.Save(); + }, _configService.Current.LightlessPairRequestNotification); + ImGui.TableSetColumnIndex(2); + availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_pair", new Vector2(availableWidth, 0))) + { + _lightlessNotificationService.ShowPairRequestNotification( + "Test User", + "test-uid-123", + () => + { + Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.", + NotificationType.Info)); + }, + () => + { + Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.", + NotificationType.Info)); + } + ); + } + } + UiSharedService.AttachToolTip("Test pair request notification"); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Download Progress Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessDownloadNotification = location; + _configService.Save(); + }, _configService.Current.LightlessDownloadNotification); + ImGui.TableSetColumnIndex(2); + availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_download", new Vector2(availableWidth, 0))) + { + _lightlessNotificationService.ShowPairDownloadNotification( + new List<(string playerName, float progress, string status)> + { + ("Player One", 0.35f, "downloading"), + ("Player Two", 0.75f, "downloading"), + ("Player Three", 1.0f, "downloading") + }, + queueWaiting: 2 + ); + } + } + UiSharedService.AttachToolTip("Test download progress notification"); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Performance Notifications"); + ImGui.TableSetColumnIndex(1); + ImGui.SetNextItemWidth(-1); + _uiShared.DrawCombo("###enhanced_performance", lightlessLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessPerformanceNotification = location; + _configService.Save(); + }, _configService.Current.LightlessPerformanceNotification); + ImGui.TableSetColumnIndex(2); + availableWidth = ImGui.GetContentRegionAvail().X; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_performance", new Vector2(availableWidth, 0))) + { + var testUserData = new UserData("TEST123", "TestUser", false, false, false, null, null); + Mediator.Publish(new PerformanceNotificationMessage( + "Test Player (TestUser) exceeds performance threshold(s)", + "Player Test Player (TestUser) exceeds your configured VRAM warning threshold\n500 MB/300 MB", + testUserData, + false, + "Test Player" + )); + } + } + UiSharedService.AttachToolTip("Test performance notification"); + + ImGui.EndTable(); + } + + ImGuiHelpers.ScaledDummy(5); + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear All Notifications")) + { + Mediator.Publish(new ClearAllNotificationsMessage()); + } + _uiShared.DrawHelpText("Dismiss all active notifications immediately."); } else { @@ -3602,73 +3733,6 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); if (useLightlessNotifications) { - if (_uiShared.MediumTreeNode("Test Notifications", UIColors.Get("LightlessPurple"))) - { - ImGui.Indent(); - - // Test notification buttons - if (_uiShared.IconTextButton(FontAwesomeIcon.Bell, "Test Info")) - { - Mediator.Publish(new NotificationMessage("Test Info", - "This is a test info notification to let you know Chocola is cute :3", NotificationType.Info)); - } - - ImGui.SameLine(); - if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationTriangle, "Test Warning")) - { - Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!", - NotificationType.Warning)); - } - - ImGui.SameLine(); - if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationCircle, "Test Error")) - { - Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!", - NotificationType.Error)); - } - - ImGuiHelpers.ScaledDummy(3); - if (_uiShared.IconTextButton(FontAwesomeIcon.UserPlus, "Test Pair Request")) - { - _lightlessNotificationService.ShowPairRequestNotification( - "Test User", - "test-uid-123", - () => - { - Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.", - NotificationType.Info)); - }, - () => - { - Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.", - NotificationType.Info)); - } - ); - } - - ImGui.SameLine(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Download, "Test Download Progress")) - { - _lightlessNotificationService.ShowPairDownloadNotification( - new List<(string playerName, float progress, string status)> - { - ("Player One", 0.35f, "downloading"), - ("Player Two", 0.75f, "downloading"), - ("Player Three", 1.0f, "downloading") - }, - queueWaiting: 2 - ); - } - - _uiShared.DrawHelpText("Preview how notifications will appear with your current settings."); - - ImGui.Unindent(); - - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - } - - ImGui.Separator(); if (_uiShared.MediumTreeNode("Basic Settings", UIColors.Get("LightlessPurple"))) { int maxNotifications = _configService.Current.MaxSimultaneousNotifications; @@ -3768,10 +3832,28 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); ImGui.TextUnformatted("Position"); - int offsetY = _configService.Current.NotificationOffsetY; - if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 500)) + var currentCorner = _configService.Current.NotificationCorner; + if (ImGui.BeginCombo("Notification Position", GetNotificationCornerLabel(currentCorner))) { - _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 500); + foreach (NotificationCorner corner in Enum.GetValues(typeof(NotificationCorner))) + { + bool isSelected = currentCorner == corner; + if (ImGui.Selectable(GetNotificationCornerLabel(corner), isSelected)) + { + _configService.Current.NotificationCorner = corner; + _configService.Save(); + } + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + _uiShared.DrawHelpText("Choose which corner of the screen notifications appear in."); + + int offsetY = _configService.Current.NotificationOffsetY; + if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 1000)) + { + _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 1000); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3781,7 +3863,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default (50)."); - _uiShared.DrawHelpText("Move notifications down from the top-right corner."); + _uiShared.DrawHelpText("Distance from the top edge of the screen."); int offsetX = _configService.Current.NotificationOffsetX; if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500)) @@ -3802,9 +3884,9 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TextUnformatted("Animation Settings"); float animSpeed = _configService.Current.NotificationAnimationSpeed; - if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 30f, "%.1f")) + if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 20f, "%.1f")) { - _configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 30f); + _configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 20f); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3816,6 +3898,21 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Right click to reset to default (10)."); _uiShared.DrawHelpText("How fast notifications slide in/out. Higher = faster."); + float slideSpeed = _configService.Current.NotificationSlideSpeed; + if (ImGui.SliderFloat("Slide Speed", ref slideSpeed, 1f, 20f, "%.1f")) + { + _configService.Current.NotificationSlideSpeed = Math.Clamp(slideSpeed, 1f, 20f); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationSlideSpeed = 10f; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (10)."); + _uiShared.DrawHelpText("How fast notifications slide into position when others disappear. Higher = faster."); + ImGui.Spacing(); ImGui.TextUnformatted("Visual Effects"); @@ -3915,6 +4012,20 @@ public class SettingsUi : WindowMediatorSubscriberBase if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default (300)."); + int performanceDuration = _configService.Current.PerformanceNotificationDurationSeconds; + if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 60)) + { + _configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 60); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.PerformanceNotificationDurationSeconds = 20; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (20)."); + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -3982,6 +4093,38 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TreePop(); } + if (_uiShared.MediumTreeNode("Pair Request Notifications", UIColors.Get("PairBlue"))) + { + var showPairRequestActions = _configService.Current.ShowPairRequestNotificationActions; + if (ImGui.Checkbox("Show action buttons on pair requests", ref showPairRequestActions)) + { + _configService.Current.ShowPairRequestNotificationActions = showPairRequestActions; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "When you receive a pair request, show Accept/Decline buttons in the notification."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + if (_uiShared.MediumTreeNode("Performance Notifications", UIColors.Get("LightlessOrange"))) + { + var showPerformanceActions = _configService.Current.ShowPerformanceNotificationActions; + if (ImGui.Checkbox("Show action buttons on performance warnings", ref showPerformanceActions)) + { + _configService.Current.ShowPerformanceNotificationActions = showPerformanceActions; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "When a player exceeds performance thresholds or is auto-paused, show Pause/Unpause buttons in the notification."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessOrange"), 1.5f); + ImGui.TreePop(); + } + if (_uiShared.MediumTreeNode("System Notifications", UIColors.Get("LightlessYellow"))) { var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings; @@ -3999,6 +4142,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); // Location descriptions removed - information is now inline with each setting + } } @@ -4014,8 +4158,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { return new[] { - NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, - NotificationLocation.TextOverlay, NotificationLocation.Nowhere + NotificationLocation.LightlessUi, NotificationLocation.TextOverlay, NotificationLocation.Nowhere }; } @@ -4043,6 +4186,16 @@ public class SettingsUi : WindowMediatorSubscriberBase }; } + private string GetNotificationCornerLabel(NotificationCorner corner) + { + return corner switch + { + NotificationCorner.Right => "Right", + NotificationCorner.Left => "Left", + _ => corner.ToString() + }; + } + private void DrawSoundTable() { var soundEffects = new[] @@ -4068,7 +4221,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u), ("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u), ("Pair Request", 3, _configService.Current.PairRequestSoundId, _configService.Current.DisablePairRequestSound, 5u), - ("Download", 4, _configService.Current.DownloadSoundId, _configService.Current.DisableDownloadSound, 15u) + ("Performance", 4, _configService.Current.PerformanceSoundId, _configService.Current.DisablePerformanceSound, 16u) }; foreach (var (typeName, typeIndex, currentSoundId, isDisabled, defaultSoundId) in soundTypes) @@ -4087,7 +4240,7 @@ public class SettingsUi : WindowMediatorSubscriberBase var currentIndex = Array.FindIndex(soundEffects, s => s.Item1 == currentSoundId); if (currentIndex == -1) currentIndex = 1; - ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + ImGui.SetNextItemWidth(-1); if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex, soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length)) { @@ -4098,7 +4251,7 @@ public class SettingsUi : WindowMediatorSubscriberBase case 1: _configService.Current.CustomWarningSoundId = newSoundId; break; case 2: _configService.Current.CustomErrorSoundId = newSoundId; break; case 3: _configService.Current.PairRequestSoundId = newSoundId; break; - case 4: _configService.Current.DownloadSoundId = newSoundId; break; + case 4: _configService.Current.PerformanceSoundId = newSoundId; break; } _configService.Save(); @@ -4152,7 +4305,7 @@ public class SettingsUi : WindowMediatorSubscriberBase case 1: _configService.Current.DisableWarningSound = newDisabled; break; case 2: _configService.Current.DisableErrorSound = newDisabled; break; case 3: _configService.Current.DisablePairRequestSound = newDisabled; break; - case 4: _configService.Current.DisableDownloadSound = newDisabled; break; + case 4: _configService.Current.DisablePerformanceSound = newDisabled; break; } _configService.Save(); } @@ -4178,7 +4331,7 @@ public class SettingsUi : WindowMediatorSubscriberBase case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break; case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break; case 3: _configService.Current.PairRequestSoundId = defaultSoundId; break; - case 4: _configService.Current.DownloadSoundId = defaultSoundId; break; + case 4: _configService.Current.PerformanceSoundId = defaultSoundId; break; } _configService.Save(); } diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 971d40c..d7f5605 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -88,7 +88,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGuiHelpers.ScaledDummy(0.5f); ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessYellow2")); + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue")); if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) { @@ -288,8 +288,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } - var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal); - if (updatedList != null) { var previousGid = GetSelectedGid(); diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 2a6a236..b4327c0 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -196,82 +196,6 @@ public class TopTabMenu if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); - #if DEBUG - if (ImGui.Button("Test Pair Request")) - { - _lightlessNotificationService.ShowPairRequestNotification( - "Debug User", - "debug-user-id", - onAccept: () => - { - _lightlessMediator.Publish(new NotificationMessage( - "Pair Accepted", - "Debug pair request was accepted!", - NotificationType.Info, - TimeSpan.FromSeconds(3))); - }, - onDecline: () => - { - _lightlessMediator.Publish(new NotificationMessage( - "Pair Declined", - "Debug pair request was declined.", - NotificationType.Warning, - TimeSpan.FromSeconds(3))); - } - ); - } - - ImGui.SameLine(); - if (ImGui.Button("Test Info")) - { - _lightlessMediator.Publish(new NotificationMessage( - "Information", - "This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps.", - NotificationType.Info, - TimeSpan.FromSeconds(5))); - } - - ImGui.SameLine(); - if (ImGui.Button("Test Warning")) - { - _lightlessMediator.Publish(new NotificationMessage( - "Warning", - "This is a test warning notification.", - NotificationType.Warning, - TimeSpan.FromSeconds(7))); - } - - ImGui.SameLine(); - if (ImGui.Button("Test Error")) - { - _lightlessMediator.Publish(new NotificationMessage( - "Error", - "This is a test error notification erp police", - NotificationType.Error, - TimeSpan.FromSeconds(10))); - } - - if (ImGui.Button("Test Download Progress")) - { - var downloadStatus = new List<(string playerName, float progress, string status)> - { - ("Mauwmauw Nekochan", 0.85f, "downloading"), - ("Raelynn Kitsune", 0.34f, "downloading"), - ("Jaina Elraeth", 0.67f, "downloading"), - ("Vaelstra Bloodthorn", 0.19f, "downloading"), - ("Lydia Hera Moondrop", 0.86f, "downloading"), - ("C'liina Star", 1.0f, "completed") - }; - - _lightlessNotificationService.ShowPairDownloadNotification(downloadStatus); - } - ImGui.SameLine(); - if (ImGui.Button("Dismiss Download")) - { - _lightlessNotificationService.DismissPairDownloadNotification(); - } - #endif - DrawIncomingPairRequests(availableWidth); ImGui.Separator(); diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 993573d..3c1eabd 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -11,21 +11,17 @@ namespace LightlessSync.UI { "LightlessPurple", "#ad8af5" }, { "LightlessPurpleActive", "#be9eff" }, { "LightlessPurpleDefault", "#9375d1" }, - { "ButtonDefault", "#323232" }, { "FullBlack", "#000000" }, - { "LightlessBlue", "#a6c2ff" }, { "LightlessYellow", "#ffe97a" }, - { "LightlessYellow2", "#cfbd63" }, { "LightlessGreen", "#7cd68a" }, + { "LightlessOrange", "#ffb366" }, { "PairBlue", "#88a2db" }, { "DimRed", "#d44444" }, - { "LightlessAdminText", "#ffd663" }, { "LightlessAdminGlow", "#b09343" }, { "LightlessModeratorText", "#94ffda" }, - { "LightlessModeratorGlow", "#599c84" }, { "Lightfinder", "#ad8af5" }, { "LightfinderEdge", "#000000" }, diff --git a/LightlessSync/Utils/Crypto.cs b/LightlessSync/Utils/Crypto.cs index 8ed6ecb..de04d26 100644 --- a/LightlessSync/Utils/Crypto.cs +++ b/LightlessSync/Utils/Crypto.cs @@ -1,4 +1,7 @@ -using System.Security.Cryptography; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; using System.Text; namespace LightlessSync.Utils; @@ -13,8 +16,9 @@ public static class Crypto 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 string GetHash256(this (string, ushort) playerToHash) diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 3f48af2..cc82d04 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -5,12 +5,18 @@ using LightlessSync.API.Dto.Files; using LightlessSync.API.Routes; using LightlessSync.FileCache; using LightlessSync.PlayerData.Handlers; +using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; +using System; using System.Collections.Concurrent; +using System.IO; using System.Net; using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.LightlessConfiguration; namespace LightlessSync.WebAPI.Files; @@ -20,17 +26,27 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; + private readonly PairProcessingLimiter _pairProcessingLimiter; + private readonly LightlessConfigService _configService; private readonly ConcurrentDictionary _activeDownloadStreams; + private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30); + private volatile bool _disableDirectDownloads; + private int _consecutiveDirectDownloadFailures; + private bool _lastConfigDirectDownloadsState; public FileDownloadManager(ILogger 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(StringComparer.Ordinal); _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; + _pairProcessingLimiter = pairProcessingLimiter; + _configService = configService; _activeDownloadStreams = new(); + _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; Mediator.Subscribe(this, (msg) => { @@ -50,6 +66,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public bool IsDownloading => CurrentDownloads.Any(); + private bool ShouldUseDirectDownloads() + { + return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads; + } + public static void MungeBuffer(Span buffer) { for (int i = 0; i < buffer.Length; ++i) @@ -156,39 +177,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup); } + var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId); + + await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false); + } + + private delegate void DownloadDataCallback(Span data); + + private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress 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 (HttpRequestException ex) { + response?.Dispose(); Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode); if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized) @@ -196,42 +225,80 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex); } - throw; + throw; } } + ThrottledStream? stream = null; FileStream? fileStream = null; try { - fileStream = File.Create(tempPath); + fileStream = File.Create(destinationFilename); await using (fileStream.ConfigureAwait(false)) { - var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196; + var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196; var buffer = new byte[bufferSize]; - var bytesRead = 0; var limit = _orchestrator.DownloadLimitPerSlot(); - Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath); + Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename); stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit); - _activeDownloadStreams.TryAdd(stream, 0); - while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0) + while (true) { ct.ThrowIfCancellationRequested(); + int bytesRead; + try + { + var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask(); + while (!readTask.IsCompleted) + { + var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false); + if (completedTask == readTask) + { + break; + } - MungeBuffer(buffer.AsSpan(0, bytesRead)); + ct.ThrowIfCancellationRequested(); + + var snapshot = _pairProcessingLimiter.GetSnapshot(); + if (snapshot.Waiting > 0) + { + throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})"); + } + + Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl); + } + + bytesRead = await readTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + + if (bytesRead == 0) + { + break; + } + + callback?.Invoke(buffer.AsSpan(0, bytesRead)); await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false); progress.Report(bytesRead); } - Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath); + Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename); } } + catch (TimeoutException ex) + { + Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl); + throw; + } catch (OperationCanceledException) { throw; @@ -240,18 +307,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { try { - fileStream?.Close(); + fileStream?.Close(); - if (!string.IsNullOrEmpty(tempPath) && File.Exists(tempPath)) + if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename)) { - File.Delete(tempPath); + File.Delete(destinationFilename); } } catch { - // Ignore errors during cleanup + // ignore cleanup errors } - throw; + throw; } finally { @@ -260,6 +327,134 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _activeDownloadStreams.TryRemove(stream, out _); await stream.DisposeAsync().ConfigureAwait(false); } + + response?.Dispose(); + } + } + + private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List 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 fileReplacement, + IProgress progress, CancellationToken token, bool slotAlreadyAcquired) + { + if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) + { + throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); + } + + var downloadKey = directDownload.DirectDownloadUrl!; + bool slotAcquiredHere = false; + string? blockFile = null; + + try + { + if (!slotAlreadyAcquired) + { + if (_downloadStatus.TryGetValue(downloadKey, out var tracker)) + { + tracker.DownloadStatus = DownloadStatus.WaitingForSlot; + } + + await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); + slotAcquiredHere = true; + } + + if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker)) + { + queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue; + } + + var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri), + new[] { directDownload.Hash }, token).ConfigureAwait(false); + var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"')); + + blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); + + await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false); + + if (!File.Exists(blockFile)) + { + throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); + } + + await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false); + } + finally + { + if (slotAcquiredHere) + { + _orchestrator.ReleaseDownloadSlot(); + } + + if (!string.IsNullOrEmpty(blockFile)) + { + try + { + File.Delete(blockFile); + } + catch + { + // ignore cleanup errors + } + } } } @@ -307,30 +502,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List 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(); + var batchDownloads = new List(); + + foreach (var download in CurrentDownloads) + { + if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads) + { + directDownloads.Add(download); + } + else + { + batchDownloads.Add(download); + } + } + + var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray(); + + foreach (var directDownload in directDownloads) + { + _downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus() { DownloadStatus = DownloadStatus.Initializing, - TotalBytes = downloadGroup.Sum(c => c.Total), + TotalBytes = directDownload.Total, TotalFiles = 1, TransferredBytes = 0, TransferredFiles = 0 }; } + foreach (var downloadBatch in downloadBatches) + { + _downloadStatus[downloadBatch.Key] = new FileDownloadStatus() + { + DownloadStatus = DownloadStatus.Initializing, + TotalBytes = downloadBatch.Sum(c => c.Total), + TotalFiles = 1, + TransferredBytes = 0, + TransferredFiles = 0 + }; + } + + if (directDownloads.Count > 0 || downloadBatches.Length > 0) + { + Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length); + } + Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); - await Parallel.ForEachAsync(downloadGroups, new ParallelOptions() + Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions() { - MaxDegreeOfParallelism = downloadGroups.Count(), + MaxDegreeOfParallelism = downloadBatches.Length, CancellationToken = ct, }, async (fileGroup, token) => { - // let server predownload files var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri), fileGroup.Select(c => c.Hash), token).ConfigureAwait(false); Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri, @@ -353,7 +594,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot; await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue; - Progress progress = new((bytesDownloaded) => + var progress = CreateInlineProgress((bytesDownloaded) => { try { @@ -371,7 +612,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } catch (OperationCanceledException) { - Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler); + Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName); } catch (Exception ex) { @@ -382,72 +623,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return; } - FileStream? fileBlockStream = null; try { - if (_downloadStatus.TryGetValue(fileGroup.Key, out var status)) - { - status.TransferredFiles = 1; - status.DownloadStatus = DownloadStatus.Decompressing; - } if (!File.Exists(blockFile)) { Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); return; } - fileBlockStream = File.OpenRead(blockFile); - while (fileBlockStream.Position < fileBlockStream.Length) - { - (string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream); - - try - { - var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1]; - var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension); - Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath); - - byte[] compressedFileContent = new byte[fileLengthBytes]; - var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false); - if (readBytes != fileLengthBytes) - { - throw new EndOfStreamException(); - } - MungeBuffer(compressedFileContent); - - var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent); - await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false); - - PersistFileToStorage(fileHash, filePath); - } - catch (EndOfStreamException) - { - Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash); - } - catch (Exception e) - { - Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name); - } - } - } - catch (EndOfStreamException) - { - Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name); - } - catch (Exception ex) - { - Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name); + await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false); } finally { _orchestrator.ReleaseDownloadSlot(); - if (fileBlockStream != null) - await fileBlockStream.DisposeAsync().ConfigureAwait(false); File.Delete(blockFile); } - }).ConfigureAwait(false); + }); - Logger.LogDebug("Download end: {id}", gameObjectHandler); + Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions() + { + MaxDegreeOfParallelism = directDownloads.Count, + CancellationToken = ct, + }, + async (directDownload, token) => + { + if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker)) + { + Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl); + return; + } + + var progress = CreateInlineProgress((bytesDownloaded) => + { + try + { + if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value)) + { + value.TransferredBytes += bytesDownloaded; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Could not set download progress"); + } + }); + + if (!ShouldUseDirectDownloads()) + { + await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false); + return; + } + + var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); + var slotAcquired = false; + + + try + { + downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot; + await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); + slotAcquired = true; + + downloadTracker.DownloadStatus = DownloadStatus.Downloading; + Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); + await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false); + + Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0); + + downloadTracker.DownloadStatus = DownloadStatus.Decompressing; + + try + { + var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase)); + if (replacement == null || replacement.GamePaths.Length == 0) + { + Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); + return; + } + + var fileExtension = replacement.GamePaths[0].Split(".")[^1]; + var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension); + Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename); + byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false); + var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false); + PersistFileToStorage(directDownload.Hash, finalFilename); + + downloadTracker.TransferredFiles = 1; + Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + } + catch (Exception ex) + { + Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); + } + } + catch (OperationCanceledException ex) + { + Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash); + Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash); + ClearDownload(); + return; + } + catch (Exception ex) + { + var expectedDirectDownloadFailure = ex is InvalidDataException; + var failureCount = 0; + + if (expectedDirectDownloadFailure) + { + Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash); + } + else + { + failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures); + Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash); + } + + try + { + downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue; + await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false); + + if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) + { + _disableDirectDownloads = true; + Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount); + } + } + catch (Exception fallbackEx) + { + if (slotAcquired) + { + _orchestrator.ReleaseDownloadSlot(); + slotAcquired = false; + } + + Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); + ClearDownload(); + return; + } + } + finally + { + if (slotAcquired) + { + _orchestrator.ReleaseDownloadSlot(); + } + + try + { + File.Delete(tempFilename); + } + catch + { + // ignore + } + } + }); + + await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false); + + Logger.LogDebug("Download end: {id}", objectName); ClearDownload(); } @@ -554,4 +890,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _orchestrator.ClearDownloadRequest(requestId); } } -} \ No newline at end of file + + private static IProgress CreateInlineProgress(Action callback) + { + return new InlineProgress(callback); + } + + private sealed class InlineProgress : IProgress + { + private readonly Action _callback; + + public InlineProgress(Action callback) + { + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + } + + public void Report(long value) + { + _callback(value); + } + } +} diff --git a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs index 690ea79..de84a81 100644 --- a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs +++ b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs @@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase } public async Task 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 SendRequestAsync(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class + public async Task SendRequestAsync(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 SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct) + public async Task 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 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) { diff --git a/LightlessSync/WebAPI/Files/Models/DownloadFileTransfer.cs b/LightlessSync/WebAPI/Files/Models/DownloadFileTransfer.cs index effb461..d0e9fd4 100644 --- a/LightlessSync/WebAPI/Files/Models/DownloadFileTransfer.cs +++ b/LightlessSync/WebAPI/Files/Models/DownloadFileTransfer.cs @@ -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;