Skip to content
Shiny.NET

Screen Recorder

NuGet package Shiny.AppDeviceBridge.ScreenRecorder

The screen recorder bridge records the device’s screen to a video and saves it in a file root, where the page can play, upload or delete it. It’s built on Shiny.ScreenRecorder.

In a browser, a page records the screen with getDisplayMedia. The WebViews on iOS and Android don’t offer it, and desktop WebViews vary, so a page inside the app uses this bridge instead. A page running in a normal browser can keep using getDisplayMedia, or Shiny.ScreenRecorder on Blazor WebAssembly.

Platform Backed by What’s recorded Asks the user
Android MediaProjection the whole screen every recording, with the system’s consent dialog
iOS, Mac Catalyst ReplayKit this app only once, with ReplayKit’s prompt
macOS ScreenCaptureKit the whole screen once, for Screen Recording permission in System Settings
Windows Windows.Graphics.Capture the whole screen no: it only draws a yellow border. See Confirming a recording
Linux xdg-desktop-portal ScreenCast the whole screen every recording, with the compositor’s picker

The page chooses nothing about what is recorded. It’s always the primary display, or the app itself on iOS and Mac Catalyst. Picking a display or window is left out on purpose, because listing them would give the page every other app’s window titles.

What each device can do beyond recording differs too. GET /_bridge/screenrecorder lists it in capabilities:

Capability Request field Notes
Recording missing where nothing can record, such as Linux without a portal or an encoder
PauseResume pause / resume routes not on macOS 15’s capture path
Microphone includeMicrophone not on Windows
SystemAudio includeSystemAudio everything playing on macOS and Linux; only the app’s own audio on iOS and Android; none on Windows
CursorToggle showCursor: false desktops only; touch screens have no pointer
FrameRateControl frameRate a ceiling from 1 to 240; a still screen produces no frames
BitrateControl videoBitrate bits a second
Downscaling maxWidth worth setting on Retina displays and modern phones

A request for something that isn’t listed answers 501 before anyone is asked anything, rather than recording without it.

Terminal window
dotnet add package Shiny.AppDeviceBridge.ScreenRecorder
builder.UseAppDeviceBridge(
bridge => bridge.AddScreenRecorderBridge(o => o.MaxDuration = TimeSpan.FromMinutes(10)),
webApp => { /* … */ }
);

AddScreenRecorderBridge() also registers Shiny.ScreenRecorder’s recorder for the platform, or the portal recorder on Linux, so there’s nothing else to call.

ScreenRecorderBridgeOptions Default
Root data the file root recordings are saved into
Folder screen-recordings the folder inside Root; each recording is REC_<yyyyMMdd-HHmmss>.mp4 and never overwrites another
MaxDuration 1 hour the longest any recording runs; longer requests are shortened to it, and null removes the limit
ConfirmStart null asked on the main thread before every recording; return false to refuse it
RegisterScreenRecorder true turn off to register your own IScreenRecorder
Platform Details
Android FOREGROUND_SERVICE and FOREGROUND_SERVICE_MEDIA_PROJECTION, plus RECORD_AUDIO for the microphone. The recorder’s service and consent activity merge into the manifest automatically. Word the recording’s notification with an IScreenRecordingNotificationDelegate.
iOS and Mac Catalyst NSMicrophoneUsageDescription for the microphone. The app has to be in the foreground.
macOS the OS prompts for Screen Recording permission itself. Add NSMicrophoneUsageDescription for the microphone, and com.apple.security.device.audio-input when sandboxed.
Windows Windows 10 1903 or later. Packaged apps declare the graphicsCapture capability.
Linux xdg-desktop-portal with ScreenCast (GNOME, KDE Plasma, wlroots), and GStreamer (gstreamer1.0-tools gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-pipewire) or, on X11, ffmpeg. Without them, the bridge reports no capabilities and answers 501. Flatpak hosts aren’t supported.
// Program.cs (Blazor WebAssembly)
builder.Services.AddWebAppHostClient().AddScreenRecorderBridgeClient();
@inject IScreenRecorderBridge Recorder
var status = await Recorder.GetStatusAsync();
if (status.Capabilities.Contains(ScreenRecorderCapability.Recording))
{
await using var ended = await Recorder.OnEndedAsync(e => Show(e.Reason, e.Recording));
// Returns once frames are being written, which is after the user answers any consent dialog.
await Recorder.StartAsync(new ScreenRecordingRequest(
IncludeMicrophone: status.Capabilities.Contains(ScreenRecorderCapability.Microphone),
MaxDurationSeconds: 120
));
// …
var recording = await Recorder.StopAsync(); // recording.File is a BridgeFile for the files bridge
}
import { ScreenRecorderBridge } from "@shinyorg/appdevicebridge";
const recorder = new ScreenRecorderBridge();
const stop = await recorder.onEnded(e => console.log(e.reason, e.recording?.file.path));
await recorder.start({ maxWidth: 1280, maxDurationSeconds: 120 });
const { file, durationSeconds } = await recorder.stop();
video.src = `/_bridge/files/${file.root}/content?path=${encodeURIComponent(file.path)}`;

Every route, its body and its response is listed under Requests, and both events under Events.

  • Access ahead of time. Android can’t grant the screen before a recording starts, so POST …/access reports Unknown there; treat anything but Denied as worth trying.
  • Errors: 501 not_supported (the device can’t record, or can’t honour a setting), 409 recording_busy (one at a time, including a recording the app started itself), 409 not_recording (stop, pause or resume with nothing running), 403 permission_denied (the user declined), 403 declined (ConfirmStart said no), 400 for a bad body.
  • Stopping takes a moment. Finishing the file and writing its index isn’t instant on a long recording. A page that gives up waiting doesn’t interrupt it.

The page isn’t the only one that can stop a recording. The user can stop it from Android’s notification, the macOS menu bar or the compositor. The OS can interrupt it for a call or a locked screen. MaxDuration can run out. Each of these ends the recording on the device. Whatever could be salvaged is saved just as a stop would have saved it, and screenrecorder.ended reports it:

reason recording
Stopped always: the page stopped it
Cancelled never
MaxDurationReached always: the file is complete
RevokedByUser usually
InterruptedBySystem, TargetLost when something could be kept
EncoderFailed rarely; message says what went wrong
Unknown when something could be kept

Listen for screenrecorder.ended rather than assuming a stop will find a recording running. A stop that comes too late answers 409 not_recording, and the recording is already in lastRecording.

This bridge records the whole screen on four of the five platforms, so it’s the widest device access any bridge gives. Every route requires the bridge policy, which by default admits callers on this device only. Every platform except Windows also asks the user itself.

On Windows, give the user a say with ConfirmStart:

bridge.AddScreenRecorderBridge(o => o.ConfirmStart = async (request, ct) =>
await Application.Current!.Windows[0].Page!.DisplayAlertAsync(
"Record your screen?",
request.IncludeMicrophone ? "The web app wants to record your screen and microphone." : "The web app wants to record your screen.",
"Record",
"Don't allow"
));

Keep MaxDuration set. It’s what stops a recording that a page started and then forgot, for example after the page navigated away or crashed.

GET/_bridge/screenrecorder#

What the recorder can do and where it is, without prompting.

C#IScreenRecorderBridge.GetStatusAsyncTSScreenRecorderBridge.getStatus
Response
200 ScreenRecorderStatus
ScreenRecorderStatus · 6 fields
supportedbooleanWhether this device can record its screen at all.
capabilitiesScreenRecorderCapability[]What it can do.
stateScreenRecorderStateWhere it is.
elapsedSecondsnumber | nullHow much has been recorded, without any paused span. Null when nothing is recording.
maxDurationSecondsnumber | nullThe longest a recording may run on this device; a longer request is shortened to it.
lastRecordingScreenRecording | nullThe most recent recording the bridge filed.
POST/_bridge/screenrecorder/access#

Asks for what recording needs, where the platform lets it be asked ahead of time.

C#IScreenRecorderBridge.RequestAccessAsyncTSScreenRecorderBridge.requestAccess
Body
application/json · ScreenRecorderAccessRequest
ScreenRecorderAccessRequest · 2 fields
includeMicrophoneboolean
includeSystemAudioboolean
Response
200 ScreenRecorderAccessResult
ScreenRecorderAccessResult · 1 field
accessAccessStateWhere recording stands after asking. Android cannot grant the screen ahead of time, so it reports Unknown there and asks when a recording starts: anything but Denied is worth trying.
POST/_bridge/screenrecorder/recording#

Starts recording and answers once frames are being written — after any consent the platform asks for — with the state it is in.

C#IScreenRecorderBridge.StartAsyncTSScreenRecorderBridge.start
Body
application/json · ScreenRecordingRequest
ScreenRecordingRequest · 7 fields
includeMicrophonebooleanMix in the microphone. Needs Microphone.
includeSystemAudiobooleanCapture what the device is playing. Needs SystemAudio.
showCursorboolean | nullDraw the pointer. Null or true draws it; false needs CursorToggle.
frameRatenumber | nullA ceiling, 1–240. Needs FrameRateControl.
videoBitratenumber | nullBits a second. Needs BitrateControl.
maxWidthnumber | nullDownscale to at most this many pixels wide. Needs Downscaling.
maxDurationSecondsnumber | nullStop and file it after this long. Never longer than maxDurationSeconds, which also applies when this is null.
Response
200 ScreenRecorderStatus
ScreenRecorderStatus · 6 fields
supportedbooleanWhether this device can record its screen at all.
capabilitiesScreenRecorderCapability[]What it can do.
stateScreenRecorderStateWhere it is.
elapsedSecondsnumber | nullHow much has been recorded, without any paused span. Null when nothing is recording.
maxDurationSecondsnumber | nullThe longest a recording may run on this device; a longer request is shortened to it.
lastRecordingScreenRecording | nullThe most recent recording the bridge filed.
DELETE/_bridge/screenrecorder/recording#

Stops recording, finishes the file and files it. Finishing takes a moment on a long recording.

C#IScreenRecorderBridge.StopAsyncTSScreenRecorderBridge.stop
Response
200 ScreenRecording
ScreenRecording · 6 fields
fileBridgeFileWhere it was filed.
sizenumberBytes.
widthnumberEncoded width in pixels.
heightnumberEncoded height in pixels.
durationSecondsnumberHow long it runs, without any paused span.
mimeTypestringWhat was written — video/mp4 on every native platform.
POST/_bridge/screenrecorder/recording/cancel#

Ends the recording and keeps nothing.

C#IScreenRecorderBridge.CancelAsyncTSScreenRecorderBridge.cancel
Response
204 No content
POST/_bridge/screenrecorder/recording/pause#

Stops writing frames without ending the recording. Needs PauseResume.

C#IScreenRecorderBridge.PauseAsyncTSScreenRecorderBridge.pause
Response
204 No content
POST/_bridge/screenrecorder/recording/resume#

Carries on after pause.

C#IScreenRecorderBridge.ResumeAsyncTSScreenRecorderBridge.resume
Response
204 No content
EVENTscreenrecorder.status#

The recorder changed state — starting, recording, paused, stopping, idle. Raised with the whole status.

C#IScreenRecorderBridge.OnStatusAsyncTSScreenRecorderBridge.onStatus
Payload
ScreenRecorderStatus
supportedbooleanWhether this device can record its screen at all.
capabilitiesScreenRecorderCapability[]What it can do.
stateScreenRecorderStateWhere it is.
elapsedSecondsnumber | nullHow much has been recorded, without any paused span. Null when nothing is recording.
maxDurationSecondsnumber | nullThe longest a recording may run on this device; a longer request is shortened to it.
lastRecordingScreenRecording | nullThe most recent recording the bridge filed.
EVENTscreenrecorder.ended#

A recording ended, however it ended, with the recording when one was filed.

C#IScreenRecorderBridge.OnEndedAsyncTSScreenRecorderBridge.onEnded
Payload
ScreenRecordingEnded
reasonScreenRecordingEndReasonWhy.
recordingScreenRecording | nullThe recording, when there was one to file.
messagestring | nullWhat went wrong, when something did.

JSON property names are camelCase and enums travel as their names. A ? marks a property the payload may omit;| null one that may be null.

ScreenRecorderStatusobject#

The recorder as it is right now.

supportedbooleanWhether this device can record its screen at all.
capabilitiesScreenRecorderCapability[]What it can do.
stateScreenRecorderStateWhere it is.
elapsedSecondsnumber | nullHow much has been recorded, without any paused span. Null when nothing is recording.
maxDurationSecondsnumber | nullThe longest a recording may run on this device; a longer request is shortened to it.
lastRecordingScreenRecording | nullThe most recent recording the bridge filed.
ScreenRecorderCapabilityenum#

What this device's recorder can do. A request that asks for something missing here answers 501 rather than recording without it.

"Recording""PauseResume""Microphone""SystemAudio""CursorToggle""FrameRateControl""BitrateControl""Downscaling"
ScreenRecorderStateenum#

Where the recorder is.

"Idle""Starting""Recording""Paused""Stopping"
ScreenRecordingobject#

A recording, filed into a file root and read through the files bridge.

fileBridgeFileWhere it was filed.
sizenumberBytes.
widthnumberEncoded width in pixels.
heightnumberEncoded height in pixels.
durationSecondsnumberHow long it runs, without any paused span.
mimeTypestringWhat was written — video/mp4 on every native platform.
BridgeFileobject#

A file the page can reach through the files bridge: a named root and a path inside it. Bridges that take or return a file use this, so nothing reaches the page that /_bridge/files would not also serve.

rootstring
pathstring
ScreenRecorderAccessRequestobject#

Which audio to ask for along with the screen. Only the microphone has a permission of its own.

includeMicrophone?boolean
includeSystemAudio?boolean
ScreenRecorderAccessResultobject#
accessAccessStateWhere recording stands after asking. Android cannot grant the screen ahead of time, so it reports Unknown there and asks when a recording starts: anything but Denied is worth trying.
AccessStateenum#

Where a permission stands. Reported by every bridge that needs one; the names match Shiny's own.

"Unknown""NotSupported""NotSetup""Disabled""Restricted""Denied""Available"
ScreenRecordingRequestobject#

What to record. Every setting but the audio is optional, and each one needs its ScreenRecorderCapability: a request for something this device cannot do answers 501.

includeMicrophone?booleanMix in the microphone. Needs Microphone.
includeSystemAudio?booleanCapture what the device is playing. Needs SystemAudio.
showCursor?boolean | nullDraw the pointer. Null or true draws it; false needs CursorToggle.
frameRate?number | nullA ceiling, 1–240. Needs FrameRateControl.
videoBitrate?number | nullBits a second. Needs BitrateControl.
maxWidth?number | nullDownscale to at most this many pixels wide. Needs Downscaling.
maxDurationSeconds?number | nullStop and file it after this long. Never longer than maxDurationSeconds, which also applies when this is null.
ScreenRecordingEndedobject#

A recording ended — stopped, cancelled, or ended by the device.

reasonScreenRecordingEndReasonWhy.
recordingScreenRecording | nullThe recording, when there was one to file.
messagestring | nullWhat went wrong, when something did.
ScreenRecordingEndReasonenum#

Why a recording ended.

"Unknown""Stopped""Cancelled""MaxDurationReached""RevokedByUser""InterruptedBySystem""TargetLost""EncoderFailed"