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 = []; private byte _transferBoxTransparency = 100; 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.LightlessUi; 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) { DrawTransferBar(); } } private void DrawTransferBar() { const int dlBarBorder = 3; const float rounding = 6f; var shadowOffset = new Vector2(2, 2); List>> transfers; try { transfers = [.. _currentDownloads]; } catch (ArgumentException) { return; } foreach (var transfer in transfers) { var transferKey = transfer.Key; // Skip if no valid game object if (transferKey.GetGameObject() == null) continue; 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); // Per-player state counts var dlSlot = 0; var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; foreach (var entry in transfer.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; } } string statusText; if (dlProg > 0) { statusText = "Downloading"; } else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes)) { statusText = "Decompressing"; } else if (dlQueue > 0) { statusText = "Waiting for queue"; } else if (dlSlot > 0) { statusText = "Waiting for slot"; } else { statusText = "Waiting"; } var hasValidSize = totalBytes > 0; 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(); drawList.AddRectFilled( outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, 100 / 2), rounding + 2 ); drawList.AddRectFilled( outerStart, outerEnd, UiSharedService.Color(0, 0, 0, 100), rounding + 2 ); drawList.AddRectFilled( borderStart, borderEnd, UiSharedService.Color(ImGuiColors.DalamudGrey), rounding ); drawList.AddRectFilled( dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, 100), rounding ); bool showFill = false; double fillPercent = 0.0; if (hasValidSize) { if (dlProg > 0) { fillPercent = transferredBytes / (double)totalBytes; showFill = true; } else if (dlDecomp > 0 || transferredBytes >= totalBytes) { fillPercent = 1.0; showFill = true; } } if (showFill) { if (fillPercent < 0) fillPercent = 0; if (fillPercent > 1) fillPercent = 1; var progressEndX = dlBarStart.X + (float)(fillPercent * dlBarWidth); var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); drawList.AddRectFilled( dlBarStart, progressEnd, UiSharedService.Color(UIColors.Get("LightlessPurple")), rounding ); } if (_configService.Current.TransferBarsShowText) { string downloadText; if (dlProg > 0 && hasValidSize) { downloadText = $"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; } else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize) { downloadText = "Decompressing"; } else { // Waiting states downloadText = statusText; } var actualTextSize = ImGui.CalcTextSize(downloadText); UiSharedService.DrawOutlinedFont( drawList, downloadText, screenPos with { X = screenPos.X - actualTextSize.X / 2f - 1, Y = screenPos.Y - actualTextSize.Y / 2f - 1 }, UiSharedService.Color(ImGuiColors.DalamudGrey), UiSharedService.Color(0, 0, 0, 100), 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, 100), 2 ); } catch { _logger.LogDebug("Error drawing upload progress"); } } } } private void DrawDownloadSummaryBox() { if (_currentDownloads.IsEmpty) return; 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 totalDlSlot = 0; var totalDlQueue = 0; var totalDlProg = 0; var totalDlDecomp = 0; var perPlayer = new List<( string Name, int TransferredFiles, int TotalFiles, long TransferredBytes, long TotalBytes, double SpeedBytesPerSecond, int DlSlot, int DlQueue, int DlProg, int DlDecomp)>(); foreach (var transfer in _currentDownloads) { 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; // per-player W/Q/P/D var playerDlSlot = 0; var playerDlQueue = 0; var playerDlProg = 0; var playerDlDecomp = 0; foreach (var entry in transfer.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { case DownloadStatus.WaitingForSlot: playerDlSlot++; totalDlSlot++; break; case DownloadStatus.WaitingForQueue: playerDlQueue++; totalDlQueue++; break; case DownloadStatus.Downloading: playerDlProg++; totalDlProg++; break; case DownloadStatus.Decompressing: playerDlDecomp++; totalDlDecomp++; break; } } double speed = 0; if (playerTotalBytes > 0) { if (!_downloadSpeeds.TryGetValue(handler, out var tracker)) { tracker = new DownloadSpeedTracker(windowSeconds: 3.0); _downloadSpeeds[handler] = tracker; } speed = tracker.Update(now, playerTransferredBytes); } perPlayer.Add(( handler.Name, playerTransferredFiles, playerTotalFiles, playerTransferredBytes, playerTotalBytes, speed, playerDlSlot, playerDlQueue, playerDlProg, playerDlDecomp )); } // Clean speed trackers for players with no active downloads foreach (var handler in _downloadSpeeds.Keys.ToList()) { if (!_currentDownloads.ContainsKey(handler)) _downloadSpeeds.Remove(handler); } if (totalFiles == 0 || totalBytes == 0) return; // max speed for per-player bar scale (clamped) double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0; if (maxSpeed <= 0) maxSpeed = 1; var drawList = ImGui.GetBackgroundDrawList(); var windowPos = ImGui.GetWindowPos(); // Overall texts var headerText = $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]"; 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 in lightspeed..."; var headerSize = ImGui.CalcTextSize(headerText); var bytesSize = ImGui.CalcTextSize(bytesText); var totalSpeedSize = ImGui.CalcTextSize(speedText); float contentWidth = headerSize.X; if (bytesSize.X > contentWidth) contentWidth = bytesSize.X; if (totalSpeedSize.X > contentWidth) contentWidth = totalSpeedSize.X; if (_configService.Current.ShowPlayerLinesTransferWindow) { foreach (var p in perPlayer) { var line = $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; var lineSize = ImGui.CalcTextSize(line); if (lineSize.X > contentWidth) contentWidth = lineSize.X; } } var lineHeight = ImGui.GetTextLineHeight(); var globalBarHeight = lineHeight * 0.8f; var perPlayerBarHeight = lineHeight * 0.4f; // Box width float boxWidth = contentWidth + padding * 2; if (boxWidth < minBoxWidth) boxWidth = minBoxWidth; // Box height float boxHeight = 0; boxHeight += padding; boxHeight += globalBarHeight; boxHeight += padding; boxHeight += lineHeight + spacingY; boxHeight += lineHeight + spacingY; boxHeight += lineHeight * 1.4f + spacingY; if (_configService.Current.ShowPlayerLinesTransferWindow) { foreach (var p in perPlayer) { boxHeight += lineHeight + spacingY; var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow && p.TransferredBytes > 0; if (showBar) { boxHeight += perPlayerBarHeight + spacingY; } } } boxHeight += padding; var boxMin = windowPos; var boxMax = new Vector2(windowPos.X + boxWidth, windowPos.Y + boxHeight); // Background + border drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 5f); drawList.AddRect(boxMin, boxMax, UiSharedService.Color(ImGuiColors.DalamudGrey), 5f); var cursor = boxMin + new Vector2(padding, padding); var barMin = cursor; var barMax = new Vector2(boxMin.X + boxWidth - padding, cursor.Y + globalBarHeight); var progress = (float)transferredBytes / totalBytes; if (progress < 0f) progress = 0f; if (progress > 1f) progress = 1f; drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, _transferBoxTransparency), 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, _transferBoxTransparency), 1 ); cursor.Y += lineHeight + spacingY; // Bytes UiSharedService.DrawOutlinedFont( drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); cursor.Y += lineHeight + spacingY; // Total speed UiSharedService.DrawOutlinedFont( drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); cursor.Y += lineHeight * 1.4f + spacingY; var orderedPlayers = perPlayer.OrderByDescending(p => p.TotalBytes).ToList(); foreach (var p in orderedPlayers) { var hasSpeed = p.SpeedBytesPerSecond > 0; var playerSpeedText = hasSpeed ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" : "-"; var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow && p.TransferredBytes > 0; var labelLine = $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; if (!showBar) { UiSharedService.DrawOutlinedFont( drawList, labelLine, cursor, UiSharedService.Color(255, 255, 255, _transferBoxTransparency), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); cursor.Y += lineHeight + spacingY; continue; } UiSharedService.DrawOutlinedFont( drawList, labelLine, cursor, UiSharedService.Color(255, 255, 255, _transferBoxTransparency), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); cursor.Y += lineHeight + spacingY; // Bar background var barBgMin = new Vector2(boxMin.X + padding, cursor.Y); var barBgMax = new Vector2(boxMax.X - padding, cursor.Y + perPlayerBarHeight); drawList.AddRectFilled( barBgMin, barBgMax, UiSharedService.Color(40, 40, 40, _transferBoxTransparency), 3f ); // Fill based on Progress of download float ratio = 0f; if (p.TotalBytes > 0) ratio = (float)p.TransferredBytes / p.TotalBytes; if (ratio < 0f) ratio = 0f; if (ratio > 1f) ratio = 1f; var fillX = barBgMin.X + (barBgMax.X - barBgMin.X) * ratio; var barFillMax = new Vector2(fillX, barBgMax.Y); drawList.AddRectFilled( barBgMin, barFillMax, UiSharedService.Color(UIColors.Get("LightlessPurple")), 3f ); // Text inside bar: downloading vs decompressing string barText; var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0; if (isDecompressing) { // Keep bar full, static text showing decompressing barText = "Decompressing..."; } else { var bytesInside = $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}"; barText = hasSpeed ? $"{bytesInside} @ {playerSpeedText}" : bytesInside; } if (!string.IsNullOrEmpty(barText)) { var barTextSize = ImGui.CalcTextSize(barText); var barTextPos = new Vector2( barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 ); UiSharedService.DrawOutlinedFont( drawList, barText, barTextPos, UiSharedService.Color(255, 255, 255, _transferBoxTransparency), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); } cursor.Y += perPlayerBarHeight + 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)); } } } private sealed class DownloadSpeedTracker { private readonly Queue<(double Time, long Bytes)> _samples = new(); private readonly double _windowSeconds; public double SpeedBytesPerSecond { get; private set; } public DownloadSpeedTracker(double windowSeconds = 3.0) { _windowSeconds = windowSeconds; } public double Update(double now, long totalBytes) { if (_samples.Count > 0 && totalBytes < _samples.Last().Bytes) { _samples.Clear(); } _samples.Enqueue((now, totalBytes)); while (_samples.Count > 0 && now - _samples.Peek().Time > _windowSeconds) _samples.Dequeue(); if (_samples.Count < 2) { SpeedBytesPerSecond = 0; return SpeedBytesPerSecond; } var oldest = _samples.Peek(); var newest = _samples.Last(); var dt = newest.Time - oldest.Time; if (dt <= 0.0001) { SpeedBytesPerSecond = 0; return SpeedBytesPerSecond; } var dBytes = newest.Bytes - oldest.Bytes; if (dBytes <= 0) { SpeedBytesPerSecond = 0; return SpeedBytesPerSecond; } const long minBytesForSpeed = 32 * 1024; if (dBytes < minBytesForSpeed) { return SpeedBytesPerSecond; } var avg = dBytes / dt; const double alpha = 0.3; SpeedBytesPerSecond = SpeedBytesPerSecond <= 0 ? avg : SpeedBytesPerSecond * (1 - alpha) + avg * alpha; return SpeedBytesPerSecond; } } }