Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ on:
description: "Version number (leave empty for auto)"
required: false
type: string
pull_request:
branches:
- main
push:
branches:
- main
Expand Down
21 changes: 16 additions & 5 deletions ControlR.Streamer/Models/CaptureResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public sealed class CaptureResult : IDisposable
public Bitmap? Bitmap { get; init; }

public Rectangle[] DirtyRects { get; init; } = [];
public CaptureResult? PreviousResult { get; init; }
public bool DxTimedOut { get; init; }
public Exception? Exception { get; init; }
public string FailureReason { get; init; } = string.Empty;
Expand All @@ -25,20 +26,25 @@ public void Dispose()
Bitmap?.Dispose();
}

internal static CaptureResult Fail(string failureReason)
internal static CaptureResult Fail(string failureReason, CaptureResult? dxCaptureResult = null)
{
return new CaptureResult()
{
FailureReason = failureReason
FailureReason = failureReason,
PreviousResult = dxCaptureResult,
};
}

internal static CaptureResult Fail(Exception exception, string? failureReason = null)
internal static CaptureResult Fail(
Exception exception,
string? failureReason = null,
CaptureResult? dxCaptureResult = null)
{
return new CaptureResult()
{
FailureReason = failureReason ?? exception.Message,
Exception = exception,
PreviousResult = dxCaptureResult,
};
}

Expand All @@ -59,14 +65,19 @@ internal static CaptureResult NoChanges()
};
}

internal static CaptureResult Ok(Bitmap bitmap, bool isUsingGpu, Rectangle[]? dirtyRects = default)
internal static CaptureResult Ok(
Bitmap bitmap,
bool isUsingGpu,
Rectangle[]? dirtyRects = default,
CaptureResult? dxCaptureResult = null)
{
return new CaptureResult()
{
Bitmap = bitmap,
IsSuccess = true,
IsUsingGpu = isUsingGpu,
DirtyRects = dirtyRects ?? []
DirtyRects = dirtyRects ?? [],
PreviousResult = dxCaptureResult,
};
}

Expand Down
25 changes: 21 additions & 4 deletions ControlR.Streamer/Services/CaptureMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public interface ICaptureMetrics
void SetIsUsingGpu(bool isUsingGpu);
void Start(CancellationToken cancellationToken);
void Stop();
Task WaitForBandwidth(CancellationToken cancellationToken);
}

internal sealed class CaptureMetrics(
Expand All @@ -44,6 +45,7 @@ internal sealed class CaptureMetrics(
private readonly SemaphoreSlim _processLock = new(1, 1);
private readonly TimeProvider _timeProvider = timeProvider;
private readonly TimeSpan _timerInterval = TimeSpan.FromSeconds(.1);
private readonly ManualResetEventAsync _bandwidthAvailableSignal = new(false);
private CancellationTokenSource? _abortTokenSource;
private double _fps;
private double _ips;
Expand Down Expand Up @@ -180,13 +182,22 @@ private void ProcessMetrics(object? state)
_mbps = 0;
}

while (
_iterations.TryPeek(out var iteration) &&
iteration.AddSeconds(1) < _timeProvider.GetUtcNow())
if (_mbps >= MaxMbps && _bandwidthAvailableSignal.IsSet)
{
_bandwidthAvailableSignal.Reset();
}
else if (_mbps < MaxMbps && !_bandwidthAvailableSignal.IsSet)
{
_ = _iterations.TryDequeue(out _);
_bandwidthAvailableSignal.Set();
}

while (
_iterations.TryPeek(out var iteration) &&
iteration.AddSeconds(1) < _timeProvider.GetUtcNow())
{
_ = _iterations.TryDequeue(out _);
}

_ips = _iterations.Count;

var calculatedQuality = (int)(TargetMbps / _mbps * DefaultImageQuality);
Expand All @@ -208,5 +219,11 @@ private void ProcessMetrics(object? state)
_processLock.Release();
}
}

public async Task WaitForBandwidth(CancellationToken cancellationToken)
{
await _bandwidthAvailableSignal.Wait(cancellationToken);
}

private record SentPayload(int Size, DateTimeOffset Timestamp);
}
53 changes: 30 additions & 23 deletions ControlR.Streamer/Services/DesktopCapturer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ internal class DesktopCapturer : IDesktopCapturer
private readonly IHostApplicationLifetime _appLifetime;
private readonly IBitmapUtility _bitmapUtility;
private readonly ConcurrentQueue<ScreenRegionDto> _changedRegions = new();
private readonly IDelayer _delayer;
private readonly ICaptureMetrics _captureMetrics;
private readonly AutoResetEventAsync _frameReadySignal = new();
private readonly AutoResetEventAsync _frameRequestedSignal = new(true);
private readonly ILogger<DesktopCapturer> _logger;
private readonly IMemoryProvider _memoryProvider;
private readonly TimeProvider _timeProvider;
private readonly IScreenGrabber _screenGrabber;
private readonly IOptions<StartupOptions> _startupOptions;
private readonly IWin32Interop _win32Interop;
Expand All @@ -48,21 +48,21 @@ internal class DesktopCapturer : IDesktopCapturer


public DesktopCapturer(
TimeProvider timeProvider,
IScreenGrabber screenGrabber,
IBitmapUtility bitmapUtility,
IMemoryProvider memoryProvider,
IWin32Interop win32Interop,
IDelayer delayer,
ICaptureMetrics captureMetrics,
IHostApplicationLifetime appLifetime,
IOptions<StartupOptions> startupOptions,
ILogger<DesktopCapturer> logger)
{
_timeProvider = timeProvider;
_screenGrabber = screenGrabber;
_bitmapUtility = bitmapUtility;
_memoryProvider = memoryProvider;
_win32Interop = win32Interop;
_delayer = delayer;
_captureMetrics = captureMetrics;
_startupOptions = startupOptions;
_appLifetime = appLifetime;
Expand Down Expand Up @@ -165,14 +165,14 @@ private async Task EncodeCpuCaptureResult(CaptureResult captureResult, int quali
if (!diffResult.IsSuccess)
{
_logger.LogError(diffResult.Exception, "Failed to get changed area. Reason: {ErrorReason}", diffResult.Reason);
await _delayer.Delay(_afterFailureDelay, cancellationToken);
await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken);
return;
}

var diffArea = diffResult.Value;
if (diffArea.IsEmpty)
{
await _delayer.Delay(_afterFailureDelay, cancellationToken);
await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken);
return;
}

Expand All @@ -197,7 +197,7 @@ private async Task EncodeGpuCaptureResult(CaptureResult captureResult, int quali

if (captureResult.DirtyRects.Length == 0)
{
await _delayer.Delay(_afterFailureDelay);
await Task.Delay(_afterFailureDelay, _timeProvider);
return;
}

Expand Down Expand Up @@ -294,7 +294,7 @@ private async Task StartCapturingChangesImpl(CancellationToken cancellationToken
if (_selectedDisplay is not { } selectedDisplay)
{
_logger.LogWarning("Selected display is null. Unable to capture latest frame.");
await _delayer.Delay(_afterFailureDelay, cancellationToken);
await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken);
continue;
}

Expand All @@ -304,26 +304,31 @@ private async Task StartCapturingChangesImpl(CancellationToken cancellationToken

if (captureResult.HadNoChanges)
{
await _delayer.Delay(_afterFailureDelay, cancellationToken);
await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken);
continue;
}

if (captureResult.DxTimedOut)
{
_logger.LogDebug("DirectX capture timed out. BitBlt fallback used.");
}

if (!captureResult.IsSuccess)
{
_logger.LogWarning(captureResult.Exception, "Failed to capture latest frame. Reason: {ResultReason}",
_logger.LogWarning(
captureResult.Exception,
"Failed to capture latest frame. Reason: {ResultReason}",
captureResult.FailureReason);

ResetDisplays();
await _delayer.Delay(_afterFailureDelay, cancellationToken);
await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken);
continue;
}

if (!captureResult.IsUsingGpu && _captureMetrics.IsUsingGpu)
{
// We've switched from GPU to CPU capture, so we need to force a keyframe.
_forceKeyFrame = true;
}

_captureMetrics.SetIsUsingGpu(captureResult.IsUsingGpu);


if (ShouldSendKeyFrame())
{
EncodeRegion(captureResult.Bitmap, captureResult.Bitmap.ToRectangle(), CaptureMetrics.DefaultImageQuality, isKeyFrame: true);
Expand Down Expand Up @@ -388,13 +393,15 @@ private static Bitmap DownscaleBitmap(Bitmap bitmap, double scale)
}
private async Task ThrottleCapturing(CancellationToken cancellationToken)
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);

await _delayer.WaitForAsync(
condition: () => _captureMetrics.Mbps < CaptureMetrics.MaxMbps,
pollingDelay: TimeSpan.FromMilliseconds(10),
cancellationToken: linkedCts.Token);
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250), _timeProvider);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);
await _captureMetrics.WaitForBandwidth(linkedCts.Token);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Throttle timed out.");
}
}

}
29 changes: 16 additions & 13 deletions ControlR.Streamer/Services/ScreenGrabber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,29 +98,29 @@ public CaptureResult Capture(
return GetBitBltCapture(display.MonitorArea, captureCursor);
}

var result = GetDirectXCapture(display, captureCursor);
var dxResult = GetDirectXCapture(display, captureCursor);

if (result.HadNoChanges)
if (dxResult.HadNoChanges)
{
return result;
return dxResult;
}

if (result.DxTimedOut && allowFallbackToBitBlt)
if (dxResult.DxTimedOut && allowFallbackToBitBlt)
{
return GetBitBltCapture(display.MonitorArea, captureCursor);
return GetBitBltCapture(display.MonitorArea, captureCursor, dxResult);
}

if (!result.IsSuccess || result.Bitmap is null || _bitmapUtility.IsEmpty(result.Bitmap))
if (!dxResult.IsSuccess || dxResult.Bitmap is null || _bitmapUtility.IsEmpty(dxResult.Bitmap))
{
if (!allowFallbackToBitBlt)
{
return result;
return dxResult;
}

return GetBitBltCapture(display.MonitorArea, captureCursor);
return GetBitBltCapture(display.MonitorArea, captureCursor, dxResult);
}

return result;
return dxResult;
}
catch (Exception ex)
{
Expand Down Expand Up @@ -235,7 +235,10 @@ private unsafe Rectangle TryDrawCursor(Graphics graphics, Rectangle captureArea)
}
}

private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor)
private CaptureResult GetBitBltCapture(
Rectangle captureArea,
bool captureCursor,
CaptureResult? dxResult = null)
{
try
{
Expand All @@ -253,7 +256,7 @@ private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor

if (!bitBltResult)
{
return CaptureResult.Fail("BitBlt function failed.");
return CaptureResult.Fail("BitBlt function failed.", dxResult);
}
}

Expand All @@ -270,10 +273,10 @@ private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor
ex,
"Error getting capture with BitBlt. Capture Area: {@CaptureArea}",
captureArea);
return CaptureResult.Fail(ex);
return CaptureResult.Fail(exception: ex, dxCaptureResult: dxResult);
}
}

private CaptureResult GetDirectXCapture(DisplayInfo display, bool captureCursor)
{
var dxOutput = _dxOutputGenerator.GetDxOutput(display.DeviceName);
Expand Down
7 changes: 7 additions & 0 deletions ControlR.Web.Client/Components/Dashboard.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ private async Task<GridData<DeviceViewModel>> LoadServerData(GridState<DeviceVie
PropertyName = sd.SortBy,
Descending = sd.Descending,
SortOrder = sd.Index
})],
FilterDefinitions = [.. state.FilterDefinitions
.Select(fd => new DeviceColumnFilter
{
PropertyName = fd.Column?.PropertyName,
Operator = fd.Operator,
Value = fd.Value?.ToString()
})]
};

Expand Down
30 changes: 20 additions & 10 deletions ControlR.Web.Client/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@

<MudLayout>
<MudAppBar Elevation="1">
<MudStaticNavDrawerToggle DrawerId="nav-drawer" Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit"
Edge="Edge.Start"/>

<MudChip T="string" Text="Beta" Color="Color.Info" Class="ms-3"/>
@if (RendererInfo.IsInteractive)
{
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@((e) => ToggleNavDrawer())" />
}
else
{
<MudStaticNavDrawerToggle DrawerId="nav-drawer" Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit"
Edge="Edge.Start"/>
}

<MudImage Src=@Assets["icon-192.png"] Height="30" Class="mx-2" />
<MudLink Href="/" Underline="Underline.None">
<MudText Typo="Typo.h6" Color="Color.Primary">ControlR</MudText>
</MudLink>

<MudSpacer/>

Expand Down Expand Up @@ -73,13 +84,6 @@
ClipMode="DrawerClipMode.Always"
Elevation="2">

<MudDrawerHeader Class="ps-4">
<MudImage Src="/icon-192.png" Height="30" Class="me-2"/>
<MudLink Href="/" Underline="Underline.None">
<MudText Typo="Typo.h6" Color="Color.Primary">ControlR</MudText>
</MudLink>
</MudDrawerHeader>

<NavMenu IsDisabled="@_isWaitingForWasmLoad"/>
</MudDrawer>
<MudMainContent Class="mt-16 pa-4">
Expand Down Expand Up @@ -232,4 +236,10 @@
return Task.CompletedTask;
}


private void ToggleNavDrawer()
{
_drawerOpen = !_drawerOpen;
}

}
Loading