using Dalamud.Game.Text.SeStringHandling; using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.UI.Models; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using FFXIVClientStructs.FFXIV.Client.UI; using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType; namespace LightlessSync.Services; public class NotificationService : DisposableMediatorSubscriberBase, IHostedService { private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly DalamudUtilService _dalamudUtilService; private readonly INotificationManager _notificationManager; private readonly IChatGui _chatGui; public NotificationService( ILogger logger, LightlessConfigService configService, DalamudUtilService dalamudUtilService, INotificationManager notificationManager, IChatGui chatGui, LightlessMediator mediator) : base(logger, mediator) { _logger = logger; _configService = configService; _dalamudUtilService = dalamudUtilService; _notificationManager = notificationManager; _chatGui = chatGui; } public Task StartAsync(CancellationToken cancellationToken) { Mediator.Subscribe(this, HandleNotificationMessage); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { return 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 { Title = title, Message = message, Type = type, Duration = duration ?? TimeSpan.FromSeconds(_configService.Current.DefaultNotificationDurationSeconds), Actions = actions ?? new List(), SoundEffectId = GetSoundEffectId(type, soundEffectId), ShowProgress = _configService.Current.ShowNotificationProgress, CreatedAt = DateTime.UtcNow }; if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) { foreach (var action in notification.Actions) { var originalOnClick = action.OnClick; action.OnClick = (n) => { originalOnClick(n); if (_configService.Current.AutoDismissOnAction) { n.IsDismissed = true; n.IsAnimatingOut = true; } }; } } if (notification.SoundEffectId.HasValue && _configService.Current.EnableNotificationSounds) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) { var notification = new LightlessNotification { Title = "Pair Request Received", Message = $"{senderName} wants to pair with you.", Type = NotificationType.PairRequest, Duration = TimeSpan.FromSeconds(180), SoundEffectId = NotificationSounds.PairRequest, 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; } } } }; if (notification.SoundEffectId.HasValue) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null) { var actions = new List(); if (onOpenFolder != null) { actions.Add(new LightlessNotificationAction { Id = "open_folder", Label = "Open Folder", Icon = FontAwesomeIcon.FolderOpen, Color = UIColors.Get("LightlessBlue"), OnClick = (n) => { onOpenFolder(); n.IsDismissed = true; n.IsAnimatingOut = true; } }); } 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 }; if (notification.SoundEffectId.HasValue) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, Action? onViewLog = null) { var actions = new List(); if (onRetry != null) { actions.Add(new LightlessNotificationAction { Id = "retry", Label = "Retry", Icon = FontAwesomeIcon.Redo, Color = UIColors.Get("LightlessBlue"), OnClick = (n) => { onRetry(); n.IsDismissed = true; n.IsAnimatingOut = true; } }); } if (onViewLog != null) { actions.Add(new LightlessNotificationAction { Id = "view_log", Label = "View Log", Icon = FontAwesomeIcon.FileAlt, Color = UIColors.Get("LightlessYellow"), 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)); } 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 notification = new LightlessNotification { Id = "pair_download_progress", Title = "Downloading Pair Data", Message = message, Type = NotificationType.Download, Duration = TimeSpan.FromMinutes(5), ShowProgress = true, Progress = totalProgress }; Mediator.Publish(new LightlessNotificationMessage(notification)); if (allDownloadsCompleted) { DismissPairDownloadNotification(); } } public void DismissPairDownloadNotification() { Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); } private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId) { if (!_configService.Current.EnableNotificationSounds) return null; if (overrideSoundId.HasValue) return overrideSoundId; if (_configService.Current.UseCustomSounds) { return type switch { NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId, _ => NotificationSounds.GetDefaultSound(type) }; } return NotificationSounds.GetDefaultSound(type); } private void PlayNotificationSound(uint soundEffectId) { try { try { UIGlobals.PlayChatSoundEffect(soundEffectId); _logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId); } catch (Exception chatEx) { _logger.LogWarning(chatEx, "Failed to play sound via ChatGui for ID {SoundId}", soundEffectId); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId); } } private void HandleNotificationMessage(NotificationMessage msg) { _logger.LogInformation("{msg}", msg.ToString()); if (!_dalamudUtilService.IsLoggedIn) return; // 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 }; ShowNotificationLocationBased(msg, location); } private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location) { switch (location) { case NotificationLocation.Toast: ShowToast(msg); break; case NotificationLocation.Chat: ShowChat(msg); break; case NotificationLocation.Both: ShowToast(msg); ShowChat(msg); break; case NotificationLocation.LightlessUi: ShowLightlessNotification(msg); break; case NotificationLocation.ChatAndLightlessUi: ShowChat(msg); ShowLightlessNotification(msg); break; case NotificationLocation.Nowhere: break; } } private void ShowLightlessNotification(NotificationMessage msg) { var duration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(_configService.Current.DefaultNotificationDurationSeconds); uint? soundId = null; if (_configService.Current.EnableNotificationSounds) { if (_configService.Current.UseCustomSounds) { soundId = msg.Type switch { NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId, _ => NotificationSounds.GetDefaultSound(msg.Type) }; } else { soundId = NotificationSounds.GetDefaultSound(msg.Type); } } ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, soundId); } 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 }; _notificationManager.AddNotification(new Notification() { Content = msg.Message ?? string.Empty, Title = msg.Title, Type = dalamudType, Minimized = false, InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3) }); } private void ShowChat(NotificationMessage msg) { switch (msg.Type) { case NotificationType.Info: PrintInfoChat(msg.Message); break; case NotificationType.Warning: PrintWarnChat(msg.Message); break; case NotificationType.Error: PrintErrorChat(msg.Message); break; } } private void PrintErrorChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message); _chatGui.PrintError(se.BuiltString); } private void PrintInfoChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ").AddItalics(message ?? string.Empty); _chatGui.Print(se.BuiltString); } private void PrintWarnChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ").AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff(); _chatGui.Print(se.BuiltString); } }