diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index f2b9f12..6cd92bf 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -92,23 +92,17 @@ public class LightlessConfig : ILightlessConfiguration public int NotificationOffsetX { get; set; } = 0; public float NotificationWidth { get; set; } = 350f; public float NotificationSpacing { get; set; } = 8f; - public bool NotificationStackUpwards { get; set; } = false; // Animation & Effects public float NotificationAnimationSpeed { get; set; } = 10f; public float NotificationAccentBarWidth { get; set; } = 3f; - // Typography - public float NotificationFontScale { get; set; } = 1.0f; - // Duration per Type public int InfoNotificationDurationSeconds { get; set; } = 10; public int WarningNotificationDurationSeconds { get; set; } = 15; public int ErrorNotificationDurationSeconds { get; set; } = 20; public int PairRequestDurationSeconds { get; set; } = 180; public int DownloadNotificationDurationSeconds { get; set; } = 300; - - // Sound Settings public uint CustomInfoSoundId { get; set; } = 2; // Se2 public uint CustomWarningSoundId { get; set; } = 16; // Se15 public uint CustomErrorSoundId { get; set; } = 16; // Se15 @@ -118,7 +112,7 @@ public class LightlessConfig : ILightlessConfiguration public bool DisableWarningSound { get; set; } = false; public bool DisableErrorSound { get; set; } = false; public bool DisablePairRequestSound { get; set; } = false; - public bool DisableDownloadSound { get; set; } = true; // Disabled by default + public bool DisableDownloadSound { get; set; } = true; // Disabled by default (annoying) public bool UseFocusTarget { get; set; } = false; public bool overrideFriendColor { get; set; } = false; public bool overridePartyColor { get; set; } = false; diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 1279089..621b816 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -39,14 +39,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Subscribe(this, HandleNotificationMessage); return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info, TimeSpan? duration = null, List? actions = null, uint? soundEffectId = null) { - var notification = new LightlessNotification + var notification = CreateNotification(title, message, type, duration, actions, soundEffectId); + + if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) + { + WrapActionsWithAutoDismiss(notification); + } + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private LightlessNotification CreateNotification(string title, string message, NotificationType type, + TimeSpan? duration, List? actions, uint? soundEffectId) + { + return new LightlessNotification { Title = title, Message = message, @@ -57,30 +72,28 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ ShowProgress = _configService.Current.ShowNotificationProgress, CreatedAt = DateTime.UtcNow }; - - if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) + } + + private void WrapActionsWithAutoDismiss(LightlessNotification notification) + { + foreach (var action in notification.Actions) { - foreach (var action in notification.Actions) + var originalOnClick = action.OnClick; + action.OnClick = (n) => { - var originalOnClick = action.OnClick; - action.OnClick = (n) => + originalOnClick(n); + if (_configService.Current.AutoDismissOnAction) { - originalOnClick(n); - if (_configService.Current.AutoDismissOnAction) - { - n.IsDismissed = true; - n.IsAnimatingOut = true; - } - }; - } + DismissNotification(n); + } + }; } - - if (notification.SoundEffectId.HasValue) - { - PlayNotificationSound(notification.SoundEffectId.Value); - } - - Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private void DismissNotification(LightlessNotification notification) + { + notification.IsDismissed = true; + notification.IsAnimatingOut = true; } public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) { @@ -90,40 +103,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Message = $"{senderName} wants to directly pair with you.", Type = NotificationType.PairRequest, Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), - SoundEffectId = !_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null, - Actions = new List - { - new() - { - Id = "accept", - Label = "Accept", - Icon = FontAwesomeIcon.Check, - Color = UIColors.Get("LightlessGreen"), - IsPrimary = true, - OnClick = (n) => - { - _logger.LogInformation("Pair request accepted"); - onAccept(); - n.IsDismissed = true; - n.IsAnimatingOut = true; - } - }, - new() - { - Id = "decline", - Label = "Decline", - Icon = FontAwesomeIcon.Times, - Color = UIColors.Get("DimRed"), - IsDestructive = true, - OnClick = (n) => - { - _logger.LogInformation("Pair request declined"); - onDecline(); - n.IsDismissed = true; - n.IsAnimatingOut = true; - } - } - } + SoundEffectId = GetPairRequestSoundId(), + Actions = CreatePairRequestActions(onAccept, onDecline) }; if (notification.SoundEffectId.HasValue) @@ -133,7 +114,70 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationMessage(notification)); } + + private uint? GetPairRequestSoundId() => + !_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null; + + private List CreatePairRequestActions(Action onAccept, Action onDecline) + { + return new List + { + new() + { + Id = "accept", + Label = "Accept", + Icon = FontAwesomeIcon.Check, + Color = UIColors.Get("LightlessGreen"), + IsPrimary = true, + OnClick = (n) => + { + _logger.LogInformation("Pair request accepted"); + onAccept(); + DismissNotification(n); + } + }, + new() + { + Id = "decline", + Label = "Decline", + Icon = FontAwesomeIcon.Times, + Color = UIColors.Get("DimRed"), + IsDestructive = true, + OnClick = (n) => + { + _logger.LogInformation("Pair request declined"); + onDecline(); + DismissNotification(n); + } + } + }; + } public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null) + { + var notification = new LightlessNotification + { + Title = "Download Complete", + Message = FormatDownloadCompleteMessage(fileName, fileCount), + Type = NotificationType.Info, + Duration = TimeSpan.FromSeconds(8), + Actions = CreateDownloadCompleteActions(onOpenFolder), + SoundEffectId = NotificationSounds.DownloadComplete + }; + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private string FormatDownloadCompleteMessage(string fileName, int fileCount) => + fileCount > 1 + ? $"Downloaded {fileCount} files successfully." + : $"Downloaded {fileName} successfully."; + + private List CreateDownloadCompleteActions(Action? onOpenFolder) { var actions = new List(); @@ -148,21 +192,23 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ OnClick = (n) => { onOpenFolder(); - n.IsDismissed = true; - n.IsAnimatingOut = true; + DismissNotification(n); } }); } + + return actions; + } + public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, Action? onViewLog = null) + { var notification = new LightlessNotification { - Title = "Download Complete", - Message = fileCount > 1 ? - $"Downloaded {fileCount} files successfully." : - $"Downloaded {fileName} successfully.", - Type = NotificationType.Info, - Duration = TimeSpan.FromSeconds(8), - Actions = actions, - SoundEffectId = NotificationSounds.DownloadComplete + Title = title, + Message = FormatErrorMessage(message, exception), + Type = NotificationType.Error, + Duration = TimeSpan.FromSeconds(15), + Actions = CreateErrorActions(onRetry, onViewLog), + SoundEffectId = NotificationSounds.Error }; if (notification.SoundEffectId.HasValue) @@ -172,9 +218,14 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationMessage(notification)); } - public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, Action? onViewLog = null) + + private string FormatErrorMessage(string message, Exception? exception) => + exception != null ? $"{message}\n\nError: {exception.Message}" : message; + + private List CreateErrorActions(Action? onRetry, Action? onViewLog) { var actions = new List(); + if (onRetry != null) { actions.Add(new LightlessNotificationAction @@ -186,11 +237,11 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ OnClick = (n) => { onRetry(); - n.IsDismissed = true; - n.IsAnimatingOut = true; + DismissNotification(n); } }); } + if (onViewLog != null) { actions.Add(new LightlessNotificationAction @@ -202,65 +253,14 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ OnClick = (n) => onViewLog() }); } - var notification = new LightlessNotification - { - Title = title, - Message = exception != null ? $"{message}\n\nError: {exception.Message}" : message, - Type = NotificationType.Error, - Duration = TimeSpan.FromSeconds(15), - Actions = actions, - SoundEffectId = NotificationSounds.Error - }; - - if (notification.SoundEffectId.HasValue) - { - PlayNotificationSound(notification.SoundEffectId.Value); - } - - Mediator.Publish(new LightlessNotificationMessage(notification)); + + return actions; } public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus, int queueWaiting = 0) { var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList(); - var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f; - var completedCount = userDownloads.Count(x => x.progress >= 1.0f); - var totalCount = userDownloads.Count; - - var message = ""; - - if (queueWaiting > 0) - { - message = $"Queue: {queueWaiting} waiting"; - } - - if (totalCount > 0) - { - var progressMessage = $"Progress: {completedCount}/{totalCount} completed"; - message = string.IsNullOrEmpty(message) ? progressMessage : $"{message}\n{progressMessage}"; - } - - if (userDownloads.Any(x => x.progress < 1.0f)) - { - var maxNamesToShow = _configService.Current.MaxConcurrentPairApplications; - var activeDownloads = userDownloads.Where(x => x.progress < 1.0f).Take(maxNamesToShow); - var downloadLines = string.Join("\n", activeDownloads.Select(x => - { - var statusText = x.status switch - { - "downloading" => $"{x.progress:P0}", - "decompressing" => "decompressing", - "queued" => "queued", - "waiting" => "waiting for slot", - _ => x.status - }; - return $"• {x.playerName}: {statusText}"; - })); - - message += string.IsNullOrEmpty(message) ? downloadLines : $"\n{downloadLines}"; - } - - var allDownloadsCompleted = userDownloads.All(x => x.progress >= 1.0f) && userDownloads.Any(); + var message = BuildPairDownloadMessage(userDownloads, queueWaiting); var notification = new LightlessNotification { @@ -272,59 +272,99 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ ShowProgress = true, Progress = totalProgress }; + Mediator.Publish(new LightlessNotificationMessage(notification)); - if (allDownloadsCompleted) + + if (AreAllDownloadsCompleted(userDownloads)) { DismissPairDownloadNotification(); } } - - public void DismissPairDownloadNotification() + + private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads, int queueWaiting) { - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - } - - private TimeSpan GetDefaultDurationForType(NotificationType type) - { - return type switch + var messageParts = new List(); + + if (queueWaiting > 0) { - NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), - NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds), - NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), - NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), - NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), - _ => TimeSpan.FromSeconds(10) // Fallback for any unknown types - }; + messageParts.Add($"Queue: {queueWaiting} waiting"); + } + + if (userDownloads.Count > 0) + { + var completedCount = userDownloads.Count(x => x.progress >= 1.0f); + messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed"); + } + + var activeDownloadLines = BuildActiveDownloadLines(userDownloads); + if (!string.IsNullOrEmpty(activeDownloadLines)) + { + messageParts.Add(activeDownloadLines); + } + + return string.Join("\n", messageParts); } + + private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads) + { + var activeDownloads = userDownloads + .Where(x => x.progress < 1.0f) + .Take(_configService.Current.MaxConcurrentPairApplications); + + if (!activeDownloads.Any()) return string.Empty; + + return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}")); + } + + private string FormatDownloadStatus((string playerName, float progress, string status) download) => download.status switch + { + "downloading" => $"{download.progress:P0}", + "decompressing" => "decompressing", + "queued" => "queued", + "waiting" => "waiting for slot", + _ => download.status + }; + + private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) => + userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f); + + public void DismissPairDownloadNotification() => + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); + + private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch + { + NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), + NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds), + NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), + NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), + NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), + _ => TimeSpan.FromSeconds(10) + }; private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId) { - if (overrideSoundId.HasValue) - return overrideSoundId; - - // Check if this specific notification type is disabled - bool isDisabled = type switch - { - NotificationType.Info => _configService.Current.DisableInfoSound, - NotificationType.Warning => _configService.Current.DisableWarningSound, - NotificationType.Error => _configService.Current.DisableErrorSound, - NotificationType.Download => _configService.Current.DisableDownloadSound, - _ => false - }; - - if (isDisabled) - return null; - - // Return the configured sound for this type - return type switch - { - NotificationType.Info => _configService.Current.CustomInfoSoundId, - NotificationType.Warning => _configService.Current.CustomWarningSoundId, - NotificationType.Error => _configService.Current.CustomErrorSoundId, - NotificationType.Download => _configService.Current.DownloadSoundId, - _ => NotificationSounds.GetDefaultSound(type) - }; + if (overrideSoundId.HasValue) return overrideSoundId; + if (IsSoundDisabledForType(type)) return null; + return GetConfiguredSoundForType(type); } + + private bool IsSoundDisabledForType(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.DisableInfoSound, + NotificationType.Warning => _configService.Current.DisableWarningSound, + NotificationType.Error => _configService.Current.DisableErrorSound, + NotificationType.Download => _configService.Current.DisableDownloadSound, + _ => false + }; + + private uint GetConfiguredSoundForType(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.CustomInfoSoundId, + NotificationType.Warning => _configService.Current.CustomWarningSoundId, + NotificationType.Error => _configService.Current.CustomErrorSoundId, + NotificationType.Download => _configService.Current.DownloadSoundId, + _ => NotificationSounds.GetDefaultSound(type) + }; private void PlayNotificationSound(uint soundEffectId) { @@ -342,32 +382,36 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private void HandleNotificationMessage(NotificationMessage msg) { _logger.LogInformation("{msg}", msg.ToString()); - if (!_dalamudUtilService.IsLoggedIn) return; - // Get notification location based on type and system preference - var location = _configService.Current.UseLightlessNotifications - ? msg.Type switch - { - NotificationType.Info => _configService.Current.LightlessInfoNotification, - NotificationType.Warning => _configService.Current.LightlessWarningNotification, - NotificationType.Error => _configService.Current.LightlessErrorNotification, - NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, - NotificationType.Download => _configService.Current.LightlessDownloadNotification, - _ => NotificationLocation.LightlessUi - } - : msg.Type switch - { - NotificationType.Info => _configService.Current.InfoNotification, - NotificationType.Warning => _configService.Current.WarningNotification, - NotificationType.Error => _configService.Current.ErrorNotification, - NotificationType.PairRequest => NotificationLocation.Toast, - NotificationType.Download => NotificationLocation.Toast, - _ => NotificationLocation.Nowhere - }; - + var location = GetNotificationLocation(msg.Type); ShowNotificationLocationBased(msg, location); } + + private NotificationLocation GetNotificationLocation(NotificationType type) => + _configService.Current.UseLightlessNotifications + ? GetLightlessNotificationLocation(type) + : GetClassicNotificationLocation(type); + + private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.LightlessInfoNotification, + NotificationType.Warning => _configService.Current.LightlessWarningNotification, + NotificationType.Error => _configService.Current.LightlessErrorNotification, + NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, + NotificationType.Download => _configService.Current.LightlessDownloadNotification, + _ => NotificationLocation.LightlessUi + }; + + private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.InfoNotification, + NotificationType.Warning => _configService.Current.WarningNotification, + NotificationType.Error => _configService.Current.ErrorNotification, + NotificationType.PairRequest => NotificationLocation.Toast, + NotificationType.Download => NotificationLocation.Toast, + _ => NotificationLocation.Nowhere + }; private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location) { @@ -403,18 +447,12 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private void ShowLightlessNotification(NotificationMessage msg) { var duration = msg.TimeShownOnScreen ?? GetDefaultDurationForType(msg.Type); - // GetSoundEffectId will handle checking if the sound is disabled ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null); } private void ShowToast(NotificationMessage msg) { - Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch - { - NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, - NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, - _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info - }; + var dalamudType = ConvertToDalamudNotificationType(msg.Type); _notificationManager.AddNotification(new Notification() { @@ -425,6 +463,13 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3) }); } + + private Dalamud.Interface.ImGuiNotification.NotificationType ConvertToDalamudNotificationType(NotificationType type) => type switch + { + NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, + NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, + _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info + }; private void ShowChat(NotificationMessage msg) { diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index fd49055..139aa15 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -17,12 +17,15 @@ namespace LightlessSync.UI; public class LightlessNotificationUI : WindowMediatorSubscriberBase { + private const float NotificationMinHeight = 60f; + private const float NotificationMaxHeight = 250f; + private const float WindowPaddingOffset = 6f; + private const float SlideAnimationDistance = 100f; + private const float OutAnimationSpeedMultiplier = 0.7f; + private readonly List _notifications = new(); private readonly object _notificationLock = new(); private readonly LightlessConfigService _configService; - - private const float NotificationMinHeight = 60f; - private const float NotificationMaxHeight = 250f; public LightlessNotificationUI(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) @@ -49,15 +52,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandleNotificationDismissMessage); } - private void HandleNotificationMessage(LightlessNotificationMessage message) - { + private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification); - } - private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) - { + private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId); - } public void AddNotification(LightlessNotification notification) { @@ -66,12 +65,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id); if (existingNotification != null) { - // Update existing notification without restarting animation - existingNotification.Message = notification.Message; - existingNotification.Progress = notification.Progress; - existingNotification.ShowProgress = notification.ShowProgress; - existingNotification.Title = notification.Title; - _logger.LogDebug("Updated existing notification: {Title}", notification.Title); + UpdateExistingNotification(existingNotification, notification); } else { @@ -79,12 +73,18 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase _logger.LogDebug("Added new notification: {Title}", notification.Title); } - if (!IsOpen) - { - IsOpen = true; - } + if (!IsOpen) IsOpen = true; } } + + private void UpdateExistingNotification(LightlessNotification existing, LightlessNotification updated) + { + existing.Message = updated.Message; + existing.Progress = updated.Progress; + existing.ShowProgress = updated.ShowProgress; + existing.Title = updated.Title; + _logger.LogDebug("Updated existing notification: {Title}", updated.Title); + } public void RemoveNotification(string id) { @@ -93,11 +93,16 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase var notification = _notifications.FirstOrDefault(n => n.Id == id); if (notification != null) { - notification.IsAnimatingOut = true; - notification.IsAnimatingIn = false; + StartOutAnimation(notification); } } } + + private void StartOutAnimation(LightlessNotification notification) + { + notification.IsAnimatingOut = true; + notification.IsAnimatingIn = false; + } protected override void DrawInternal() { @@ -115,35 +120,45 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase } var viewport = ImGui.GetMainViewport(); - - // Always position at top (choco doesnt know how to handle top positions how fitting) - var baseX = viewport.WorkPos.X + viewport.WorkSize.X - _configService.Current.NotificationWidth - _configService.Current.NotificationOffsetX - 6f ; - var baseY = viewport.WorkPos.Y; - - // Apply Y offset - var finalY = baseY + _configService.Current.NotificationOffsetY; - - // Update position - Position = new Vector2(baseX, finalY); - - for (int i = 0; i < _notifications.Count; i++) - { - DrawNotification(_notifications[i], i); - - if (i < _notifications.Count - 1) - { - ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing)); - } - } + Position = CalculateWindowPosition(viewport); + 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); + } + + private void DrawAllNotifications() + { + for (int i = 0; i < _notifications.Count; i++) + { + DrawNotification(_notifications[i], i); + + if (i < _notifications.Count - 1) + { + ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing)); + } + } + } private void UpdateNotifications() { var deltaTime = ImGui.GetIO().DeltaTime; - + EnforceMaxNotificationLimit(); + UpdateAnimationsAndRemoveExpired(deltaTime); + } + + private void EnforceMaxNotificationLimit() + { var maxNotifications = _configService.Current.MaxSimultaneousNotifications; while (_notifications.Count(n => !n.IsAnimatingOut) > maxNotifications) { @@ -151,60 +166,70 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase .Where(n => !n.IsAnimatingOut) .OrderBy(n => n.CreatedAt) .FirstOrDefault(); + if (oldestNotification != null) { - oldestNotification.IsAnimatingOut = true; - oldestNotification.IsAnimatingIn = false; + StartOutAnimation(oldestNotification); } } + } + + private void UpdateAnimationsAndRemoveExpired(float deltaTime) + { for (int i = _notifications.Count - 1; i >= 0; i--) { var notification = _notifications[i]; + UpdateNotificationAnimation(notification, deltaTime); - if (notification.IsAnimatingIn && notification.AnimationProgress < 1f) - { - notification.AnimationProgress = Math.Min(1f, notification.AnimationProgress + deltaTime * _configService.Current.NotificationAnimationSpeed); - } - else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) - { - notification.AnimationProgress = Math.Max(0f, notification.AnimationProgress - deltaTime * (_configService.Current.NotificationAnimationSpeed * 0.7f)); - } - else if (!notification.IsAnimatingOut && !notification.IsDismissed) - { - notification.IsAnimatingIn = false; - - if (notification.IsExpired && !notification.IsAnimatingOut) - { - notification.IsAnimatingOut = true; - notification.IsAnimatingIn = false; - } - } - - if (notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f) + if (ShouldRemoveNotification(notification)) { _notifications.RemoveAt(i); } } } + + private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime) + { + if (notification.IsAnimatingIn && notification.AnimationProgress < 1f) + { + notification.AnimationProgress = Math.Min(1f, + notification.AnimationProgress + deltaTime * _configService.Current.NotificationAnimationSpeed); + } + else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) + { + notification.AnimationProgress = Math.Max(0f, + notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * OutAnimationSpeedMultiplier); + } + else if (!notification.IsAnimatingOut && !notification.IsDismissed) + { + notification.IsAnimatingIn = false; + + if (notification.IsExpired) + { + StartOutAnimation(notification); + } + } + } + + private bool ShouldRemoveNotification(LightlessNotification notification) => + notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; private void DrawNotification(LightlessNotification notification, int index) { var alpha = notification.AnimationProgress; - - if (alpha <= 0f) - return; - - var slideOffset = (1f - alpha) * 100f; // Fixed slide distance + if (alpha <= 0f) return; + var slideOffset = (1f - alpha) * SlideAnimationDistance; var originalCursorPos = ImGui.GetCursorPos(); ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); var notificationHeight = CalculateNotificationHeight(notification); + var notificationWidth = _configService.Current.NotificationWidth - slideOffset; ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); using var child = ImRaii.Child($"notification_{notification.Id}", - new Vector2(_configService.Current.NotificationWidth - slideOffset, notificationHeight), + new Vector2(notificationWidth, notificationHeight), false, ImGuiWindowFlags.NoScrollbar); if (child.Success) @@ -221,50 +246,69 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); + var bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered()); + var accentColor = GetNotificationAccentColor(notification.Type); + accentColor.W *= alpha; + + DrawShadow(drawList, windowPos, windowSize, alpha); + HandleClickToDismiss(notification); + DrawBackground(drawList, windowPos, windowSize, bgColor); + DrawAccentBar(drawList, windowPos, windowSize, accentColor); + DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList); + DrawNotificationText(notification, alpha); + } + + private Vector4 CalculateBackgroundColor(float alpha, bool isHovered) + { var baseOpacity = _configService.Current.NotificationOpacity; var finalOpacity = baseOpacity * alpha; var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity); - var accentColor = GetNotificationAccentColor(notification.Type); - var progressBarColor = UIColors.Get("LightlessBlue"); - accentColor.W *= alpha; + if (isHovered) + { + bgColor *= 1.1f; + bgColor.W = Math.Min(bgColor.W, 0.98f); + } - // Draw shadow with fixed intensity + return bgColor; + } + + private void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha) + { var shadowOffset = new Vector2(1f, 1f); - var shadowAlpha = 0.4f * alpha; - var shadowColor = new Vector4(0f, 0f, 0f, shadowAlpha); + var shadowColor = new Vector4(0f, 0f, 0f, 0.4f * alpha); drawList.AddRectFilled( windowPos + shadowOffset, windowPos + windowSize + shadowOffset, ImGui.ColorConvertFloat4ToU32(shadowColor), 3f ); - - var isHovered = ImGui.IsWindowHovered(); - - if (isHovered) + } + + private void HandleClickToDismiss(LightlessNotification notification) + { + if (ImGui.IsWindowHovered() && + _configService.Current.DismissNotificationOnClick && + !notification.Actions.Any() && + ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { - bgColor = bgColor * 1.1f; - bgColor.W = Math.Min(bgColor.W, 0.98f); - - // Handle click-to-dismiss for notifications without actions - if (_configService.Current.DismissNotificationOnClick && - !notification.Actions.Any() && - ImGui.IsMouseClicked(ImGuiMouseButton.Left)) - { - notification.IsDismissed = true; - notification.IsAnimatingOut = true; - } + notification.IsDismissed = true; + StartOutAnimation(notification); } - + } + + private void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor) + { drawList.AddRectFilled( windowPos, windowPos + windowSize, ImGui.ColorConvertFloat4ToU32(bgColor), 3f ); - - // Draw accent bar on left side of the notif (only if width > 0) + } + + private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor) + { var accentWidth = _configService.Current.NotificationAccentBarWidth; if (accentWidth > 0f) { @@ -275,30 +319,37 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase 3f ); } - - DrawDurationProgressBar(notification, alpha, windowPos, windowSize, progressBarColor, drawList); - - DrawNotificationText(notification, alpha); } - private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, Vector4 progressBarColor, ImDrawListPtr drawList) + private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { - // For download notifications, use download progress instead of duration - float progress; - if (notification.Type == NotificationType.Download && notification.ShowProgress) - { - progress = Math.Clamp(notification.Progress, 0f, 1f); - } - else - { - var elapsed = DateTime.UtcNow - notification.CreatedAt; - progress = Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); - } - + var progress = CalculateProgress(notification); + var progressBarColor = UIColors.Get("LightlessBlue"); var progressHeight = 2f; var progressY = windowPos.Y + windowSize.Y - progressHeight; var progressWidth = windowSize.X * progress; + DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha); + + if (progress > 0) + { + DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha); + } + } + + private float CalculateProgress(LightlessNotification notification) + { + if (notification.Type == NotificationType.Download && notification.ShowProgress) + { + return Math.Clamp(notification.Progress, 0f, 1f); + } + + var elapsed = DateTime.UtcNow - notification.CreatedAt; + return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); + } + + private void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha) + { var bgProgressColor = new Vector4(progressBarColor.X * 0.3f, progressBarColor.Y * 0.3f, progressBarColor.Z * 0.3f, 0.5f * alpha); drawList.AddRectFilled( new Vector2(windowPos.X, progressY), @@ -306,71 +357,70 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase ImGui.ColorConvertFloat4ToU32(bgProgressColor), 0f ); - - if (progress > 0) - { - var progressColor = progressBarColor; - progressColor.W *= alpha; - drawList.AddRectFilled( - new Vector2(windowPos.X, progressY), - new Vector2(windowPos.X + progressWidth, progressY + progressHeight), - ImGui.ColorConvertFloat4ToU32(progressColor), - 0f - ); - } + } + + private void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha) + { + var progressColor = progressBarColor; + progressColor.W *= alpha; + drawList.AddRectFilled( + new Vector2(windowPos.X, progressY), + new Vector2(windowPos.X + progressWidth, progressY + progressHeight), + ImGui.ColorConvertFloat4ToU32(progressColor), + 0f + ); } private void DrawNotificationText(LightlessNotification notification, float alpha) { var padding = new Vector2(10f, 6f); - var contentPos = new Vector2(padding.X, padding.Y); var windowSize = ImGui.GetWindowSize(); var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2); ImGui.SetCursorPos(contentPos); - float titleHeight = 0f; - using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) - { - // Set text wrap position to prevent title overflow - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentSize.X); - - var titleStartY = ImGui.GetCursorPosY(); - - if (_configService.Current.ShowNotificationTimestamp) - { - var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); - ImGui.TextWrapped($"[{timestamp}] {notification.Title}"); - } - else - { - ImGui.TextWrapped(notification.Title); - } - - titleHeight = ImGui.GetCursorPosY() - titleStartY; - ImGui.PopTextWrapPos(); - } - - if (!string.IsNullOrEmpty(notification.Message)) - { - ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f)); - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentSize.X); - using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha))) - { - ImGui.TextWrapped(notification.Message); - } - ImGui.PopTextWrapPos(); - } + var titleHeight = DrawTitle(notification, contentSize.X, alpha); + DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha); if (notification.Actions.Count > 0) { - var spacingHeight = ImGui.GetStyle().ItemSpacing.Y; - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + spacingHeight); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y); ImGui.SetCursorPosX(contentPos.X); DrawNotificationActions(notification, contentSize.X, alpha); } } + + private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha) + { + using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) + { + 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; + } + } + + 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))) + { + ImGui.TextWrapped(notification.Message); + } + ImGui.PopTextWrapPos(); + } private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha) { @@ -495,44 +545,43 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private float CalculateNotificationHeight(LightlessNotification notification) { - var contentWidth = _configService.Current.NotificationWidth - 35f; // Account for padding and accent bar - var height = 12f; // Base height for padding (top + bottom) + var contentWidth = _configService.Current.NotificationWidth - 35f; + var height = 12f; - var titleText = notification.Title; - if (_configService.Current.ShowNotificationTimestamp) - { - var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); - titleText = $"[{timestamp}] {titleText}"; - } + height += CalculateTitleHeight(notification, contentWidth); + height += CalculateMessageHeight(notification, contentWidth); - var titleSize = ImGui.CalcTextSize(titleText, true, contentWidth); - height += titleSize.Y; // Title height - - // Calculate message height - if (!string.IsNullOrEmpty(notification.Message)) - { - height += 4f; // Spacing between title and message - var messageSize = ImGui.CalcTextSize(notification.Message, true, contentWidth); // This is cringe - height += messageSize.Y; // Message height - } - - // Add height for progress bar if (notification.ShowProgress) { height += 12f; } - // Add height for action buttons if (notification.Actions.Count > 0) { - height += ImGui.GetStyle().ItemSpacing.Y; // Spacing before buttons - height += ImGui.GetFrameHeight(); // Button height - height += 12f; // Bottom padding for buttons + height += ImGui.GetStyle().ItemSpacing.Y; + height += ImGui.GetFrameHeight(); + height += 12f; } - // Allow notifications to grow taller but cap at maximum height return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight); } + + private float CalculateTitleHeight(LightlessNotification notification, float contentWidth) + { + var titleText = _configService.Current.ShowNotificationTimestamp + ? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}" + : notification.Title; + + return ImGui.CalcTextSize(titleText, true, contentWidth).Y; + } + + private float CalculateMessageHeight(LightlessNotification notification, float contentWidth) + { + if (string.IsNullOrEmpty(notification.Message)) return 0f; + + var messageHeight = ImGui.CalcTextSize(notification.Message, true, contentWidth).Y; + return 4f + messageHeight; + } private Vector4 GetNotificationAccentColor(NotificationType type) { diff --git a/LightlessSync/UI/Models/LightlessNotification.cs b/LightlessSync/UI/Models/LightlessNotification.cs index 4516dde..3c6edea 100644 --- a/LightlessSync/UI/Models/LightlessNotification.cs +++ b/LightlessSync/UI/Models/LightlessNotification.cs @@ -15,12 +15,9 @@ public class LightlessNotification public List Actions { get; set; } = new(); public bool ShowProgress { get; set; } = false; public float Progress { get; set; } = 0f; - public bool IsMinimized { get; set; } = false; public float AnimationProgress { get; set; } = 0f; public bool IsAnimatingIn { get; set; } = true; public bool IsAnimatingOut { get; set; } = false; - - // Sound properties public uint? SoundEffectId { get; set; } = null; } public class LightlessNotificationAction