Picture-in-Picture in Blazor: Using Browser APIs Without Fighting Blazor Server
When we need a new UI feature in Blazor, our default reflex is often to hunt down a NuGet package, a third-party component library, or write an elaborate C# wrapper. But modern browsers already handle many of these capabilities natively—often much better than a custom abstraction would.
Picture-in-Picture (PiP) is a classic example. Whether you are floating a video feed or creating an always-on-top dashboard monitor, the browser provides direct APIs to manage these detached windows.
However, wiring PiP into a Blazor Server application reveals a subtle architectural trap: browser user-activation requirements.
Here is how the Picture-in-Picture APIs work, why standard Blazor @onclick handlers break them, and how to structure JavaScript interop so the browser and server cooperate cleanly.
The Blazor Server SignalR Trap
Most browser features that affect windowing or OS integration require transient user activation. The browser will only execute methods like video.requestPictureInPicture() or documentPictureInPicture.requestWindow() if they happen directly inside a trusted user event (like a real mouse click).
In a typical Blazor setup, you might write:
<button @onclick="EnterPictureInPicture">Enter PiP</button>
@code {
private async Task EnterPictureInPicture()
{
await JS.InvokeVoidAsync("pipDemo.enterVideoPiP", "demoVideo");
}
}
This breaks in Blazor Server. Here is why:
└─► Blazor JS captures event
└─► Dispatches over SignalR websocket
└─► C# executes on server
└─► JS runtime sends websocket command back to client
└─► video.requestPictureInPicture() runs
By the time the server responds and triggers your JavaScript call, the browser’s transient user-activation window has expired. The browser sees an automated script trying to spawn a window without a direct, unbroken user gesture, and it throws an error.
The Fix: Keep Security-Sensitive Calls in Pure JS
Trigger the protected call directly in the browser's DOM event, then notify Blazor afterwards if the application state needs updating:
<button type="button" onclick="pipDemo.enterVideoPiP('demoVideo')">
Enter Picture-in-Picture
</button>
User clicks button
└─► JS click handler executes immediately
├─► requestPictureInPicture() (Allowed by browser)
└─► Dispatches event to Blazor (Updates C# state)
Implementing Video Picture-in-Picture
Let's start with a standard HTML5 <video> element inside a Blazor component:
@page "/"
<div class="video-container">
<video id="demoVideo" controls width="720">
<source src="videos/demo.mp4" type="video/mp4" />
</video>
</div>
<button type="button" onclick="pipDemo.enterVideoPiP('demoVideo')">
Enter PiP
</button>
<button type="button" onclick="pipDemo.exitVideoPiP()">
Exit PiP
</button>
The JavaScript Interop Layer
We will write a small module (wwwroot/js/pipDemo.js) to handle the API calls and hook into browser events:
window.pipDemo = {
isVideoPiPSupported: function () {
return "pictureInPictureEnabled" in document && document.pictureInPictureEnabled;
},
enterVideoPiP: async function (videoId) {
const video = document.getElementById(videoId);
if (!video || !document.pictureInPictureEnabled) return;
try {
await video.requestPictureInPicture();
} catch (err) {
console.error("Failed to enter Picture-in-Picture:", err);
}
},
exitVideoPiP: async function () {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
}
},
initializeVideo: function (videoId, dotNetReference) {
const video = document.getElementById(videoId);
if (!video) return;
video.addEventListener("enterpictureinpicture", async (event) => {
const pipWindow = event.pictureInPictureWindow;
await dotNetReference.invokeMethodAsync(
"OnPictureInPictureEntered",
pipWindow.width,
pipWindow.height
);
// Track dynamic resize events
pipWindow.addEventListener("resize", async () => {
await dotNetReference.invokeMethodAsync(
"OnPictureInPictureResized",
pipWindow.width,
pipWindow.height
);
});
});
video.addEventListener("leavepictureinpicture", async () => {
await dotNetReference.invokeMethodAsync("OnPictureInPictureExited");
});
}
};
Connecting Blazor State
We pass a DotNetObjectReference during initial render so the JavaScript event listeners can update Blazor when the window state changes:
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="status-panel">
<p>PiP Active: <strong>@IsPictureInPicture</strong></p>
@if (IsPictureInPicture)
{
<p>Dimensions: @PipWidth × @PipHeight px</p>
}
</div>
@code {
private DotNetObjectReference<Home>? _objectReference;
private bool IsPictureInPicture;
private int PipWidth;
private int PipHeight;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_objectReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("pipDemo.initializeVideo", "demoVideo", _objectReference);
}
}
[JSInvokable]
public Task OnPictureInPictureEntered(int width, int height)
{
IsPictureInPicture = true;
PipWidth = width;
PipHeight = height;
StateHasChanged();
return Task.CompletedTask;
}
[JSInvokable]
public Task OnPictureInPictureResized(int width, int height)
{
PipWidth = width;
PipHeight = height;
StateHasChanged();
return Task.CompletedTask;
}
[JSInvokable]
public Task OnPictureInPictureExited()
{
IsPictureInPicture = false;
StateHasChanged();
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
_objectReference?.Dispose();
}
}
Beyond Video: Document Picture-in-Picture
While standard PiP is strictly for media elements, the newer Document Picture-in-Picture API lets you pop arbitrary HTML into an always-on-top floating window. This is useful for persistent tools like build monitors, timers, or operational dashboards.
window.pipDemo.openDocumentPiP = async function () {
if (!("documentPictureInPicture" in window)) {
console.warn("Document Picture-in-Picture is not supported in this browser.");
return;
}
// Must be invoked directly from a user click
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 300
});
pipWindow.document.body.innerHTML = `
<div style="font-family: sans-serif; padding: 16px;">
<h3>Deployment Pipeline</h3>
<p>Status: <strong>Deploying build #4821...</strong></p>
<progress value="75" max="100" style="width: 100%;"></progress>
</div>
`;
};
Note on Browser Compatibility: As of 2026, Document PiP remains under limited availability (primarily Chromium-based browsers) and requires a secure HTTPS context. Always guard calls behind feature detection (
"documentPictureInPicture" in window) and provide a regular in-page fallback when unsupported.
The Pragmatic Takeaway
Not every browser feature needs a complex C# abstraction. Trying to hide standard browser mechanics behind elaborate service layers can introduce bugs, especially when permissions, user activation, or windowing lifecycles are involved.
When building in Blazor:
Let native HTML handle what it already does well.
Execute security-sensitive browser APIs directly in JavaScript event handlers to avoid breaking user-activation rules across SignalR.
Use Blazor where it shines: managing state, rendering core views, and coordinating application logic.

Comments
Post a Comment