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; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.UI.Models; using LightlessSync.UI.Services; 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; private readonly LightlessConfigService _configService; private readonly DalamudUtilService _dalamudUtilService; private readonly INotificationManager _notificationManager; private readonly IChatGui _chatGui; private readonly PairRequestService _pairRequestService; private readonly HashSet _shownPairRequestNotifications = new(); private readonly PairUiService _pairUiService; private readonly PairFactory _pairFactory; public NotificationService( ILogger logger, LightlessConfigService configService, DalamudUtilService dalamudUtilService, INotificationManager notificationManager, IChatGui chatGui, LightlessMediator mediator, PairRequestService pairRequestService, PairUiService pairUiService, PairFactory pairFactory) : base(logger, mediator) { _logger = logger; _configService = configService; _dalamudUtilService = dalamudUtilService; _notificationManager = notificationManager; _chatGui = chatGui; _pairRequestService = pairRequestService; _pairUiService = pairUiService; _pairFactory = pairFactory; } public Task StartAsync(CancellationToken cancellationToken) { Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandlePairRequestReceived); Mediator.Subscribe(this, HandlePairRequestsUpdated); Mediator.Subscribe(this, HandlePairDownloadStatus); Mediator.Subscribe(this, HandlePerformanceNotification); 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 = CreateNotification(title, message, type, duration, actions, soundEffectId); if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0) { 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, Type = type, Duration = duration ?? GetDefaultDurationForType(type), Actions = actions ?? new List(), SoundEffectId = GetSoundEffectId(type, soundEffectId), ShowProgress = _configService.Current.ShowNotificationProgress, CreatedAt = DateTime.UtcNow }; } private void WrapActionsWithAutoDismiss(LightlessNotification notification) { foreach (var action in notification.Actions) { var originalOnClick = action.OnClick; action.OnClick = (n) => { originalOnClick(n); if (_configService.Current.AutoDismissOnAction) { DismissNotification(n); } }; } } private static void DismissNotification(LightlessNotification notification) { notification.IsDismissed = true; notification.IsAnimatingOut = true; } public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) { var location = GetNotificationLocation(NotificationType.PairRequest); // Show in chat if configured if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi) { ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest)); } // Show Lightless notification if configured and action buttons are enabled if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi) && _configService.Current.UseLightlessNotifications && _configService.Current.ShowPairRequestNotificationActions) { var notification = new LightlessNotification { Id = $"pair_request_{senderId}", Title = "Pair Request Received", Message = $"{senderName} wants to directly pair with you.", Type = NotificationType.PairRequest, Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), SoundEffectId = GetPairRequestSoundId(), Actions = CreatePairRequestActions(onAccept, onDecline) }; if (notification.SoundEffectId.HasValue) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat) { // Fall back to regular notification without action buttons HandleNotificationMessage(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest)); } } private uint? GetPairRequestSoundId() => !_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null; private List 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 static string FormatDownloadCompleteMessage(string fileName, int fileCount) { return fileCount > 1 ? $"Downloaded {fileCount} files successfully." : $"Downloaded {fileName} successfully."; } private List CreateDownloadCompleteActions(Action? onOpenFolder) { 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(); DismissNotification(n); } }); } return actions; } public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, Action? onViewLog = null) { var notification = new LightlessNotification { Title = title, Message = FormatErrorMessage(message, exception), Type = NotificationType.Error, Duration = TimeSpan.FromSeconds(15), Actions = CreateErrorActions(onRetry, onViewLog), SoundEffectId = NotificationSounds.Error }; if (notification.SoundEffectId.HasValue) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } private static string FormatErrorMessage(string message, Exception? exception) { return 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 { Id = "retry", Label = "Retry", Icon = FontAwesomeIcon.Redo, Color = UIColors.Get("LightlessBlue"), OnClick = (n) => { onRetry(); DismissNotification(n); } }); } if (onViewLog != null) { actions.Add(new LightlessNotificationAction { Id = "view_log", Label = "View Log", Icon = FontAwesomeIcon.FileAlt, Color = UIColors.Get("LightlessYellow"), OnClick = (n) => onViewLog() }); } return actions; } private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads, int queueWaiting) { var messageParts = new List(); if (queueWaiting > 0) { messageParts.Add($"Queue: {queueWaiting} waiting"); } if (userDownloads.Count > 0) { var completedCount = userDownloads.Count(x => x.Progress >= 1.0f); messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed"); } var activeDownloadLines = BuildActiveDownloadLines(userDownloads); if (!string.IsNullOrEmpty(activeDownloadLines)) { messageParts.Add(activeDownloadLines); } return string.Join("\n", messageParts); } private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads) { var activeDownloads = userDownloads .Where(x => x.Progress < 1.0f) .Take(_configService.Current.MaxConcurrentPairApplications); if (!activeDownloads.Any()) return string.Empty; return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}")); } private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) { return download.Status switch { "downloading" => $"{download.Progress:P0}", "decompressing" => "decompressing", "queued" => "queued", "waiting" => "waiting for slot", _ => download.Status }; } private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch { NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds), NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds), _ => TimeSpan.FromSeconds(10) }; private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId) { if (overrideSoundId.HasValue) return overrideSoundId; if (IsSoundDisabledForType(type)) return null; return GetConfiguredSoundForType(type); } private bool IsSoundDisabledForType(NotificationType type) => type switch { NotificationType.Info => _configService.Current.DisableInfoSound, NotificationType.Warning => _configService.Current.DisableWarningSound, NotificationType.Error => _configService.Current.DisableErrorSound, NotificationType.Performance => _configService.Current.DisablePerformanceSound, NotificationType.Download => true, // Download sounds always disabled _ => false }; private uint GetConfiguredSoundForType(NotificationType type) => type switch { NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId, NotificationType.Performance => _configService.Current.PerformanceSoundId, _ => NotificationSounds.GetDefaultSound(type) }; private void PlayNotificationSound(uint soundEffectId) { try { UIGlobals.PlayChatSoundEffect(soundEffectId); _logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId); } } private Pair? ResolvePair(UserData userData) { var snapshot = _pairUiService.GetSnapshot(); if (snapshot.PairsByUid.TryGetValue(userData.UID, out var pair)) { return pair; } var ident = new PairUniqueIdentifier(userData.UID); return _pairFactory.Create(ident); } private void HandleNotificationMessage(NotificationMessage msg) { _logger.LogInformation("{msg}", msg.ToString()); if (!_dalamudUtilService.IsLoggedIn) return; var location = GetNotificationLocation(msg.Type); ShowNotificationLocationBased(msg, location); } private NotificationLocation GetNotificationLocation(NotificationType type) => _configService.Current.UseLightlessNotifications ? GetLightlessNotificationLocation(type) : GetClassicNotificationLocation(type); private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch { NotificationType.Info => _configService.Current.LightlessInfoNotification, NotificationType.Warning => _configService.Current.LightlessWarningNotification, NotificationType.Error => _configService.Current.LightlessErrorNotification, NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, NotificationType.Download => _configService.Current.LightlessDownloadNotification, NotificationType.Performance => _configService.Current.LightlessPerformanceNotification, _ => NotificationLocation.LightlessUi }; private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch { NotificationType.Info => _configService.Current.InfoNotification, NotificationType.Warning => _configService.Current.WarningNotification, NotificationType.Error => _configService.Current.ErrorNotification, NotificationType.PairRequest => NotificationLocation.Toast, NotificationType.Download => NotificationLocation.Toast, _ => NotificationLocation.Nowhere }; private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location) { switch (location) { 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 ?? GetDefaultDurationForType(msg.Type); ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null); } private void ShowToast(NotificationMessage msg) { var dalamudType = ConvertToDalamudNotificationType(msg.Type); _notificationManager.AddNotification(new Notification() { Content = msg.Message ?? string.Empty, Title = msg.Title, Type = dalamudType, Minimized = false, InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3) }); } private static Dalamud.Interface.ImGuiNotification.NotificationType ConvertToDalamudNotificationType(NotificationType type) { return type switch { NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info }; } private void ShowChat(NotificationMessage msg) { switch (msg.Type) { case NotificationType.Info: PrintInfoChat(msg.Message); break; case NotificationType.Warning: PrintWarnChat(msg.Message); break; case NotificationType.Error: PrintErrorChat(msg.Message); break; case NotificationType.PairRequest: PrintPairRequestChat(msg.Title, msg.Message); break; case NotificationType.Performance: PrintPerformanceChat(msg.Title, msg.Message); break; // Download notifications don't support chat output, will be a giga spam otherwise case NotificationType.Download: break; } } private void PrintErrorChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message); _chatGui.PrintError(se.BuiltString); } private void PrintInfoChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ") .AddItalics(message ?? string.Empty); _chatGui.Print(se.BuiltString); } private void PrintWarnChat(string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") .AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff(); _chatGui.Print(se.BuiltString); } private void PrintPairRequestChat(string? title, string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") .AddUiForeground("Pair Request: ", 541).AddUiForegroundOff() .AddText(title ?? message ?? string.Empty); _chatGui.Print(se.BuiltString); } private void PrintPerformanceChat(string? title, string? message) { SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") .AddUiForeground("Performance: ", 508).AddUiForegroundOff() .AddText(title ?? message ?? string.Empty); _chatGui.Print(se.BuiltString); } private void HandlePairRequestReceived(PairRequestReceivedMessage msg) { var request = _pairRequestService.RegisterIncomingRequest(msg.HashedCid, msg.Message); var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName; _shownPairRequestNotifications.Add(request.HashedCid); ShowPairRequestNotification( senderName, request.HashedCid, onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName), onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid, senderName)); } private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) { var activeRequests = _pairRequestService.GetActiveRequests(); var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal); // Dismiss notifications for requests that are no longer active (expired) var notificationsToRemove = _shownPairRequestNotifications .Where(hashedCid => !activeRequestIds.Contains(hashedCid)) .ToList(); foreach (var hashedCid in notificationsToRemove) { var notificationId = $"pair_request_{hashedCid}"; Mediator.Publish(new LightlessNotificationDismissMessage(notificationId)); _shownPairRequestNotifications.Remove(hashedCid); } } private void HandlePairDownloadStatus(PairDownloadStatusMessage msg) { var userDownloads = msg.DownloadStatus.Where(x => !string.Equals(x.PlayerName, "Pair Queue", StringComparison.Ordinal)).ToList(); var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f; var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting); var notification = new LightlessNotification { Id = "pair_download_progress", Title = "Downloading Pair Data", Message = message, Type = NotificationType.Download, Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), ShowProgress = true, Progress = totalProgress }; Mediator.Publish(new LightlessNotificationMessage(notification)); } private void HandlePerformanceNotification(PerformanceNotificationMessage msg) { var location = GetNotificationLocation(NotificationType.Performance); // Show in chat if configured if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi) { ShowChat(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance)); } // Show Lightless notification if configured and action buttons are enabled if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi) && _configService.Current.UseLightlessNotifications && _configService.Current.ShowPerformanceNotificationActions) { var actions = CreatePerformanceActions(msg.UserData, msg.IsPaused, msg.PlayerName); var notification = new LightlessNotification { Title = msg.Title, Message = msg.Message, Type = NotificationType.Performance, Duration = TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds), Actions = actions, SoundEffectId = GetSoundEffectId(NotificationType.Performance, null) }; if (notification.SoundEffectId.HasValue) { PlayNotificationSound(notification.SoundEffectId.Value); } Mediator.Publish(new LightlessNotificationMessage(notification)); } else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat) { // Fall back to regular notification without action buttons HandleNotificationMessage(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance)); } } private List 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 { var pair = ResolvePair(userData); if (pair == null) { _logger.LogWarning("Cannot cycle pause {uid} because pair is missing", userData.UID); throw new InvalidOperationException("Pair not available"); } Mediator.Publish(new CyclePauseMessage(pair)); 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 static 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})"; } }