using Dalamud.Bindings.ImGui; using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Numerics; namespace LightlessSync.UI; public class DownloadUi : WindowMediatorSubscriberBase { private readonly LightlessConfigService _configService; private readonly ConcurrentDictionary> _currentDownloads = new(); private readonly DalamudUtilService _dalamudUtilService; private readonly FileUploadManager _fileTransferManager; private readonly UiSharedService _uiShared; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); private readonly Dictionary _smoothed = []; private readonly Dictionary _downloadSpeeds = new(); private sealed class DownloadSpeedTracker { public long LastBytes; public double LastTime; public double SpeedBytesPerSecond; } private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService) { _dalamudUtilService = dalamudUtilService; _configService = configService; _pairProcessingLimiter = pairProcessingLimiter; _fileTransferManager = fileTransferManager; _uiShared = uiShared; SizeConstraints = new WindowSizeConstraints() { MaximumSize = new Vector2(500, 90), MinimumSize = new Vector2(500, 90), }; Flags |= ImGuiWindowFlags.NoMove; Flags |= ImGuiWindowFlags.NoBackground; Flags |= ImGuiWindowFlags.NoInputs; Flags |= ImGuiWindowFlags.NoNavFocus; Flags |= ImGuiWindowFlags.NoResize; Flags |= ImGuiWindowFlags.NoScrollbar; Flags |= ImGuiWindowFlags.NoTitleBar; Flags |= ImGuiWindowFlags.NoDecoration; Flags |= ImGuiWindowFlags.NoFocusOnAppearing; DisableWindowSounds = true; ForceMainWindow = true; IsOpen = true; Mediator.Subscribe(this, (msg) => { _currentDownloads[msg.DownloadId] = msg.DownloadStatus; _notificationDismissed = false; }); Mediator.Subscribe(this, (msg) => { _currentDownloads.TryRemove(msg.DownloadId, out _); // Dismiss notification if all downloads are complete if (!_currentDownloads.Any() && !_notificationDismissed) { Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); _notificationDismissed = true; _lastDownloadStateHash = 0; } }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => IsOpen = true); Mediator.Subscribe(this, (msg) => { if (msg.IsUploading) { _uploadingPlayers[msg.Handler] = true; } else { _uploadingPlayers.TryRemove(msg.Handler, out _); } }); } protected override void DrawInternal() { if (_configService.Current.ShowTransferWindow) { var limiterSnapshot = _pairProcessingLimiter.GetSnapshot(); // Check if download notifications are enabled (not set to TextOverlay) var useNotifications = _configService.Current.UseLightlessNotifications ? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay : _configService.Current.UseNotificationsForDownloads; if (useNotifications) { if (!_currentDownloads.IsEmpty) { UpdateDownloadNotificationIfChanged(limiterSnapshot); _notificationDismissed = false; } else if (!_notificationDismissed) { Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); _notificationDismissed = true; _lastDownloadStateHash = 0; } } DrawDownloadSummaryBox(); if (_configService.Current.ShowUploading) { const int transparency = 100; foreach (var player in _uploadingPlayers.Select(p => p.Key).ToList()) { var screenPos = _dalamudUtilService.WorldToScreen(player.GetGameObject()); if (screenPos == Vector2.Zero) continue; try { using var _ = _uiShared.UidFont.Push(); var uploadText = "Uploading"; var textSize = ImGui.CalcTextSize(uploadText); var drawList = ImGui.GetBackgroundDrawList(); UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(255, 255, 0, transparency), UiSharedService.Color(0, 0, 0, transparency), 2 ); } catch { _logger.LogDebug("Error drawing upload progress"); } } } } if (_configService.Current.ShowTransferBars) { const int transparency = 100; const int dlBarBorder = 3; const float rounding = 6f; var shadowOffset = new Vector2(2, 2); foreach (var transfer in _currentDownloads.ToList()) { var transferKey = transfer.Key; var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); //If RawPos is zero, remove it from smoothed dictionary if (rawPos == Vector2.Zero) { _smoothed.Remove(transferKey); continue; } //Smoothing out the movement and fix jitter around the position. Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos : rawPos; _smoothed[transferKey] = screenPos; var totalBytes = transfer.Value.Sum(c => c.Value.TotalBytes); var transferredBytes = transfer.Value.Sum(c => c.Value.TransferredBytes); var maxDlText = $"{UiSharedService.ByteToString(totalBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; var textSize = _configService.Current.TransferBarsShowText ? ImGui.CalcTextSize(maxDlText) : new Vector2(10, 10); int dlBarHeight = _configService.Current.TransferBarsHeight > ((int)textSize.Y + 5) ? _configService.Current.TransferBarsHeight : (int)textSize.Y + 5; int dlBarWidth = _configService.Current.TransferBarsWidth > ((int)textSize.X + 10) ? _configService.Current.TransferBarsWidth : (int)textSize.X + 10; var dlBarStart = new Vector2(screenPos.X - dlBarWidth / 2f, screenPos.Y - dlBarHeight / 2f); var dlBarEnd = new Vector2(screenPos.X + dlBarWidth / 2f, screenPos.Y + dlBarHeight / 2f); // Precompute rects var outerStart = new Vector2(dlBarStart.X - dlBarBorder - 1, dlBarStart.Y - dlBarBorder - 1); var outerEnd = new Vector2(dlBarEnd.X + dlBarBorder + 1, dlBarEnd.Y + dlBarBorder + 1); var borderStart = new Vector2(dlBarStart.X - dlBarBorder, dlBarStart.Y - dlBarBorder); var borderEnd = new Vector2(dlBarEnd.X + dlBarBorder, dlBarEnd.Y + dlBarBorder); var drawList = ImGui.GetBackgroundDrawList(); //Shadow, background, border, bar background drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, transparency / 2), rounding + 2); drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, transparency), rounding + 2); drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(ImGuiColors.DalamudGrey), rounding); drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, transparency), rounding); var dlProgressPercent = transferredBytes / (double)totalBytes; var progressEndX = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth); var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); drawList.AddRectFilled(dlBarStart, progressEnd, UiSharedService.Color(UIColors.Get("LightlessPurple")), rounding); if (_configService.Current.TransferBarsShowText) { var downloadText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; UiSharedService.DrawOutlinedFont(drawList, downloadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(ImGuiColors.DalamudGrey), UiSharedService.Color(0, 0, 0, transparency), 1 ); } } if (_configService.Current.ShowUploading) { foreach (var player in _uploadingPlayers.Select(p => p.Key).ToList()) { var screenPos = _dalamudUtilService.WorldToScreen(player.GetGameObject()); if (screenPos == Vector2.Zero) continue; try { using var _ = _uiShared.UidFont.Push(); var uploadText = "Uploading"; var textSize = ImGui.CalcTextSize(uploadText); var drawList = ImGui.GetBackgroundDrawList(); UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(ImGuiColors.DalamudYellow), UiSharedService.Color(0, 0, 0, transparency), 2 ); } catch { _logger.LogDebug("Error drawing upload progress"); } } } } } private void DrawDownloadSummaryBox() { if (!_currentDownloads.Any()) return; const int transparency = 150; const float padding = 6f; const float spacingY = 2f; const float minBoxWidth = 320f; var now = ImGui.GetTime(); int totalFiles = 0; int transferredFiles = 0; long totalBytes = 0; long transferredBytes = 0; var perPlayer = new List<(string Name, int TransferredFiles, int TotalFiles, long TransferredBytes, long TotalBytes, double SpeedBytesPerSecond)>(); foreach (var transfer in _currentDownloads.ToList()) { var handler = transfer.Key; var statuses = transfer.Value.Values; var playerTotalFiles = statuses.Sum(s => s.TotalFiles); var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles); var playerTotalBytes = statuses.Sum(s => s.TotalBytes); var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes); totalFiles += playerTotalFiles; transferredFiles += playerTransferredFiles; totalBytes += playerTotalBytes; transferredBytes += playerTransferredBytes; double speed = 0; if (playerTotalBytes > 0) { if (!_downloadSpeeds.TryGetValue(handler, out var tracker)) { tracker = new DownloadSpeedTracker { LastBytes = playerTransferredBytes, LastTime = now, SpeedBytesPerSecond = 0 }; _downloadSpeeds[handler] = tracker; } var dt = now - tracker.LastTime; var dBytes = playerTransferredBytes - tracker.LastBytes; if (dt > 0.1 && dBytes >= 0) { var instant = dBytes / dt; tracker.SpeedBytesPerSecond = tracker.SpeedBytesPerSecond <= 0 ? instant : tracker.SpeedBytesPerSecond * 0.8 + instant * 0.2; } tracker.LastTime = now; tracker.LastBytes = playerTransferredBytes; speed = tracker.SpeedBytesPerSecond; } perPlayer.Add(( handler.Name, playerTransferredFiles, playerTotalFiles, playerTransferredBytes, playerTotalBytes, speed )); } foreach (var handler in _downloadSpeeds.Keys.ToList()) { if (!_currentDownloads.ContainsKey(handler)) _downloadSpeeds.Remove(handler); } if (totalFiles == 0 || totalBytes == 0) return; var drawList = ImGui.GetBackgroundDrawList(); var windowPos = ImGui.GetWindowPos(); var headerText = $"Downloading {transferredFiles}/{totalFiles} files"; var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; var totalSpeed = perPlayer.Sum(p => p.SpeedBytesPerSecond); var speedText = totalSpeed > 0 ? $"{UiSharedService.ByteToString((long)totalSpeed)}/s" : "Calculating lightspeed..."; var headerSize = ImGui.CalcTextSize(headerText); var bytesSize = ImGui.CalcTextSize(bytesText); var speedSize = ImGui.CalcTextSize(speedText); float contentWidth = headerSize.X; if (bytesSize.X > contentWidth) contentWidth = bytesSize.X; if (speedSize.X > contentWidth) contentWidth = speedSize.X; foreach (var p in perPlayer) { var playerSpeedText = p.SpeedBytesPerSecond > 0 ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" : "-"; var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + $"@ {playerSpeedText}"; var lineSize = ImGui.CalcTextSize(line); if (lineSize.X > contentWidth) contentWidth = lineSize.X; } var boxWidth = contentWidth + padding * 2; if (boxWidth < minBoxWidth) boxWidth = minBoxWidth; var lineHeight = ImGui.GetTextLineHeight(); var numTextLines = 3 + perPlayer.Count; var barHeight = lineHeight * 0.8f; var boxHeight = padding * 3 + barHeight + numTextLines * (lineHeight + spacingY); var origin = windowPos; var boxMin = origin; var boxMax = origin + new Vector2(boxWidth, boxHeight); drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, transparency), 5f); drawList.AddRect(boxMin, boxMax, UiSharedService.Color(ImGuiColors.DalamudGrey), 5f); // Progress bar var cursor = boxMin + new Vector2(padding, padding); var barMin = cursor; var barMax = new Vector2(boxMin.X + boxWidth - padding, cursor.Y + barHeight); var progress = (float)transferredBytes / totalBytes; drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, transparency), 3f); drawList.AddRectFilled(barMin, new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), UiSharedService.Color(UIColors.Get("LightlessPurple")), 3f); cursor.Y = barMax.Y + padding; // Header UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; // Bytes UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; // Total speed WIP UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight * 1.4f; // Per-player lines foreach (var p in perPlayer.OrderByDescending(p => p.TotalBytes)) { var playerSpeedText = p.SpeedBytesPerSecond > 0 ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" : "-"; var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + $"@ {playerSpeedText}"; UiSharedService.DrawOutlinedFont(drawList, line, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; } } public override bool DrawConditions() { if (_uiShared.EditTrackerPosition) return true; if (!_configService.Current.ShowTransferWindow && !_configService.Current.ShowTransferBars) return false; if (_currentDownloads.IsEmpty && !_fileTransferManager.IsUploading && _uploadingPlayers.IsEmpty) return false; if (!IsOpen) return false; return true; } public override void PreDraw() { base.PreDraw(); if (_uiShared.EditTrackerPosition) { Flags &= ~ImGuiWindowFlags.NoMove; Flags &= ~ImGuiWindowFlags.NoBackground; Flags &= ~ImGuiWindowFlags.NoInputs; Flags &= ~ImGuiWindowFlags.NoResize; } else { Flags |= ImGuiWindowFlags.NoMove; Flags |= ImGuiWindowFlags.NoBackground; Flags |= ImGuiWindowFlags.NoInputs; Flags |= ImGuiWindowFlags.NoResize; } var maxHeight = ImGui.GetTextLineHeight() * (_configService.Current.ParallelDownloads + 3); SizeConstraints = new() { MinimumSize = new Vector2(300, maxHeight), MaximumSize = new Vector2(300, maxHeight), }; } private void UpdateDownloadNotificationIfChanged(PairProcessingLimiterSnapshot limiterSnapshot) { var downloadStatus = new List<(string playerName, float progress, string status)>(_currentDownloads.Count); var hashCode = new HashCode(); foreach (var item in _currentDownloads) { var dlSlot = 0; var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; long totalBytes = 0; long transferredBytes = 0; // Single pass through the dictionary to count everything - avoid multiple LINQ iterations foreach (var entry in item.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { case DownloadStatus.WaitingForSlot: dlSlot++; break; case DownloadStatus.WaitingForQueue: dlQueue++; break; case DownloadStatus.Downloading: dlProg++; break; case DownloadStatus.Decompressing: dlDecomp++; break; } totalBytes += fileStatus.TotalBytes; transferredBytes += fileStatus.TransferredBytes; } var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f; string status; if (dlDecomp > 0) status = "decompressing"; else if (dlProg > 0) status = "downloading"; else if (dlQueue > 0) status = "queued"; else if (dlSlot > 0) status = "waiting"; else status = "completed"; downloadStatus.Add((item.Key.Name, progress, status)); // Build hash from meaningful state hashCode.Add(item.Key.Name); hashCode.Add(transferredBytes); hashCode.Add(totalBytes); hashCode.Add(status); } var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0; hashCode.Add(queueWaiting); var currentHash = hashCode.ToHashCode(); // Only update notification if state has actually changed if (currentHash != _lastDownloadStateHash) { _lastDownloadStateHash = currentHash; if (downloadStatus.Count > 0 || queueWaiting > 0) { Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting)); } } } }