How to Fix True Full-Screen Video in .NET MAUI Blazor on macOS with Mac Catalyst

In .NET MAUI Blazor running on macOS through Mac Catalyst, the <video> element can usually fill only the BlazorWebView, not enter system-level full-screen. This article presents a practical fix: listen for webkitbeginfullscreen and webkitendfullscreen, bridge those events to native code, and call NSWindow.toggleFullScreen:. Keywords: MAUI Blazor, Mac Catalyst, video full-screen.

Technical specifications are straightforward

Parameter Details
Language C#, JavaScript
Runtime .NET MAUI Blazor, Mac Catalyst
Web engine WKWebView
Communication mechanism WKScriptMessageHandler
Window control AppKit NSWindow + objc_msgSend
Key events webkitbeginfullscreen, webkitendfullscreen
Core dependencies Foundation, WebKit, ObjCRuntime
Source article profile Blog post, no repository star count provided

The core issue is that Mac Catalyst does not support the desktop web full-screen pipeline

When you embed a video element in a MAUI Blazor hybrid app and the user clicks the player’s built-in full-screen button, the common result is that the video expands only to fill the BlazorWebView instead of entering a dedicated macOS full-screen Space.

That means the menu bar and Dock remain visible, and the window itself never switches into system full-screen mode. For video playback, teaching demos, and media apps, this “partial full-screen” behavior clearly falls short of desktop expectations.

// This looks correct, but it does not solve the problem on Mac Catalyst
e.Configuration.Preferences.ElementFullscreenEnabled = true;

This configuration only expresses that element full-screen is allowed. On Mac Catalyst, however, it does not connect that capability to true full-screen behavior at the system window level.

The root cause is that WKWebView inherits iOS WebKit capabilities

Mac Catalyst is built on UIKit, not pure AppKit. It allows iPad apps to move to the Mac with relatively low effort, but it also inherits the limitations of iOS WebKit.

The standard HTML5 full-screen model depends on coordination between desktop WebKit and the windowing system, such as requestFullscreen and fullscreenchange. iOS, however, does not have the same independent Space-based full-screen model as macOS, so the semantics do not fully align.

Standard full-screen events do not work here

document.addEventListener("fullscreenchange", ...) is ineffective in this scenario, and the prefixed webkitfullscreenchange is also ineffective. They do not throw errors, but in practice they are no-ops.

// These listeners typically do not fire inside WKWebView on Mac Catalyst
document.addEventListener('fullscreenchange', () => {
  console.log('standard full-screen event'); // Expected to observe desktop full-screen changes
});

document.addEventListener('webkitfullscreenchange', () => {
  console.log('Safari-prefixed event'); // Still not usable in this scenario
});

This code shows that the problem is not your implementation. The problem is the runtime capability boundary.

The only usable signals are legacy iOS video full-screen events

Although the standard events do not work, when the user clicks the full-screen button on a video element, the element still maximizes inside the WebView. That indicates that the underlying engine still exposes internal events for entering video full-screen state.

The two events that actually work are the legacy iOS events webkitbeginfullscreen and webkitendfullscreen. They are not standard desktop full-screen events, but they accurately represent the user’s intent to enter and exit video full-screen.

The correct bridge is to capture intent in JavaScript and switch the window natively

The right fix is not to force browser-standard full-screen support. Instead, build a bridge: let the front end listen for video full-screen intent, pass a Boolean value to native code, and directly control the macOS window there.

const notifyFullscreenChange = (isEnterFullscreen) => {
  // Send the web-layer full-screen intent to native code
  window.webkit.messageHandlers.fullscreenHandler.postMessage(isEnterFullscreen);
};

document.addEventListener('webkitbeginfullscreen', () => {
  notifyFullscreenChange(true); // Enter video full-screen state
}, true);

document.addEventListener('webkitendfullscreen', () => {
  notifyFullscreenChange(false); // Exit video full-screen state
}, true);

This script converts an internal WebView video state into an explicit signal that native code can consume.

The WebView layer must inject a script and register a message handler

During BlazorWebViewInitializing, you can access WKUserContentController. At this point, complete two tasks: register a WKScriptMessageHandler and inject the event-listening script into the page.

If the page contains video inside an iframe, set forMainFrameOnly to false. Otherwise, events from child frames will not be captured.

private void BlazorWebView_BlazorWebViewInitializing(object? sender,
    Microsoft.AspNetCore.Components.WebView.BlazorWebViewInitializingEventArgs e)
{
    // Register the JS -> Native message handler
    e.Configuration.UserContentController.AddScriptMessageHandler(
        new FullscreenHandler(), "fullscreenHandler");

    // Inject the video full-screen event listener script
    e.Configuration.UserContentController.AddUserScript(
        new WKUserScript(
            new NSString(@"
                const notifyFullscreenChange = (isEnterFullscreen) => {
                    window.webkit.messageHandlers.fullscreenHandler.postMessage(isEnterFullscreen);
                };
                document.addEventListener('webkitbeginfullscreen', () => notifyFullscreenChange(true), true);
                document.addEventListener('webkitendfullscreen', () => notifyFullscreenChange(false), true);
            "),
            WKUserScriptInjectionTime.AtDocumentEnd,
            true));
}

This code establishes event collection and message bridging on the WebView side, which is the entry point of the entire solution.

The native layer must switch to the main thread and control NSWindow directly

Once the JavaScript message arrives, do not manipulate UI from a background thread. The correct approach is to marshal the state back to the main thread and then execute the full-screen transition.

More importantly, do not call toggleFullScreen: blindly. You must first read styleMask to determine whether the window is already in system full-screen mode. Otherwise, you can get oscillation issues such as repeated resizing or bouncing back into full-screen after exit.

public override void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
{
    if (message.Body is NSNumber number)
    {
        bool isEnterFullscreen = number.BoolValue;
        // All window operations must run on the main thread
        MainThread.BeginInvokeOnMainThread(() => ToggleFullscreen(isEnterFullscreen));
    }
}

private static void ToggleFullscreen(bool isEnterFullscreen)
{
    var nsAppClass = new Class("NSApplication");
    var sharedAppSel = new Selector("sharedApplication");
    var nsApp = Messaging.IntPtr_objc_msgSend(nsAppClass.Handle, sharedAppSel.Handle);
    if (nsApp == IntPtr.Zero) return;

    var windowsSel = new Selector("windows");
    var windowsPtr = Messaging.IntPtr_objc_msgSend(nsApp, windowsSel.Handle);
    var windows = Runtime.GetNSObject
<NSArray>(windowsPtr);
    if (windows == null || windows.Count == 0) return;

    var nsWindow = windows.GetItem
<NSObject>(0);
    if (nsWindow == null) return;

    var styleMaskSel = new Selector("styleMask");
    var styleMask = Messaging.nuint_objc_msgSend(nsWindow.Handle, styleMaskSel.Handle);
    bool isFullscreen = (styleMask & 16384) == 16384; // 16384 is the full-screen mask bit

    if (isEnterFullscreen != isFullscreen)
    {
        var toggleFullScreenSel = new Selector("toggleFullScreen:");
        // Toggle only when the state differs to avoid window bounce
        Messaging.void_objc_msgSend_IntPtr(nsWindow.Handle, toggleFullScreenSel.Handle, IntPtr.Zero);
    }
}

The key value of this code is that it uses state validation to keep system full-screen behavior stable and predictable.

Calling objc_msgSend directly is the recommended approach

PerformSelector can invoke some Objective-C methods, but it is less direct when dealing with integer returns, structs, or specific method signatures. Using objc_msgSend here is more precise and better aligned with the Objective-C runtime model.

static partial class Messaging
{
    private const string LIBOBJC = "/usr/lib/libobjc.dylib";

    [System.Runtime.InteropServices.LibraryImport(LIBOBJC, EntryPoint = "objc_msgSend")]
    public static partial IntPtr IntPtr_objc_msgSend(IntPtr receiver, IntPtr selector);

    [System.Runtime.InteropServices.LibraryImport(LIBOBJC, EntryPoint = "objc_msgSend")]
    public static partial nuint nuint_objc_msgSend(IntPtr receiver, IntPtr selector);

    [System.Runtime.InteropServices.LibraryImport(LIBOBJC, EntryPoint = "objc_msgSend")]
    public static partial void void_objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
}

These declarations precisely match different message signatures. They work well in AOT scenarios and make it easier to extend additional AppKit operations later.

Integration requires strict platform isolation and project configuration

All code that uses ObjCRuntime, NSApplication, or NSWindow must be wrapped in #if MACCATALYST. Otherwise, you will break cross-platform compilation.

In addition, your project file should explicitly allow unsafe code so that low-level interop code can compile without restriction.


<PropertyGroup>

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

This setting ensures that the underlying interop code can be compiled correctly.

The engineering boundaries of this solution are very clear

This approach does not modify Blazor components, does not rely on private APIs, and does not damage MAUI’s cross-platform structure. The fix is isolated to Mac Catalyst platform initialization, which gives it strong locality and maintainability.

If your app uses a multi-window architecture, do not assume windows[0] is always correct. Instead, resolve the actual host window. If the video lives inside an iframe, disable the forMainFrameOnly limitation.

Windows has a more direct alternative

Compared with macOS, WebView2 on Windows directly exposes ContainsFullScreenElementChanged. Combined with AppWindow.SetPresenter, it can switch the native window into system-level full-screen with much less effort.

private void CoreWebView2_ContainsFullScreenElementChanged(CoreWebView2 sender, object args)
{
    if (sender.ContainsFullScreenElement)
    {
        AppWindow?.SetPresenter(AppWindowPresenterKind.FullScreen); // Enter window full-screen
    }
    else
    {
        AppWindow?.SetPresenter(AppWindowPresenterKind.Default); // Restore the default window mode
    }
}

This example shows that the difficulty on macOS comes mainly from the capability gap between Mac Catalyst and WKWebView, not from MAUI Blazor itself.

FAQ

FAQ 1: Why does ElementFullscreenEnabled = true still have no effect?

Because it only indicates that element full-screen is allowed within the modes that WebView understands. Mac Catalyst WKWebView does not implement the full desktop HTML5 full-screen pipeline into a real macOS Space, so the system window never truly enters full-screen.

FAQ 2: Why listen to webkitbeginfullscreen instead of fullscreenchange?

Because standard full-screen events usually do not fire on Mac Catalyst. webkitbeginfullscreen and webkitendfullscreen are legacy iOS WebKit video full-screen events, but they still work reliably and capture the user’s intent.

FAQ 3: Why must you check styleMask before toggling the window?

Because toggleFullScreen: toggles state rather than setting it explicitly. If you do not check the current state first, the window may re-enter full-screen after the user exits with Esc or the green traffic-light button, which leads to incorrect and confusing behavior.

AI Readability Summary

This article explains why HTML5 video in .NET MAUI Blazor on Mac Catalyst cannot enter true macOS full-screen by default. The practical fix is to listen for legacy iOS video full-screen events in WKWebView, forward the intent through WKScriptMessageHandler, and then call NSWindow.toggleFullScreen: from native code through objc_msgSend. With proper state checks and main-thread UI handling, you can achieve stable and predictable system-level full-screen behavior.

AI Visual Insight: The architecture is a three-step bridge: JavaScript captures video full-screen intent, WKScriptMessageHandler forwards that intent to native code, and AppKit toggles the host NSWindow into macOS full-screen only when the current state actually differs.