loading

Support, and which half of the conversation this page is

IsSupported / IsReceiver

Both halves live in the same service. A controlling page starts a presentation; the page that ends up on the second screen finds its controllers instead. IsReceiver is the branch a page that plays both roles takes at startup.

C#
@inject Bit.Butil.Presentation presentation

var isSupported = await presentation.IsSupported();

if (await presentation.IsReceiver())
{
    // this page IS the presentation - listen for the controllers that opened it
    await presentation.WatchReceiverConnections(connection => { /* ... */ });
}
Live sample
support output
Results will appear here when you interact with the samples.

Is there a display to present on?

WatchAvailability

Reports whether any display could show these URLs, immediately and on every change - so a 'present' button can stay hidden when there is nowhere to present. Some engines refuse to monitor availability continuously; a null subscription means exactly that, and the documented answer is to offer the button anyway and let Start report the truth.

C#
var subscription = await presentation.WatchAvailability(
    ["/present"],
    available => { canPresent = available; StateHasChanged(); });

await subscription!.DisposeAsync();   // monitoring costs battery
Live sample
availability output
Results will appear here when you interact with the samples.

Start a presentation and talk to it

Start / Send / GetState

Start opens the browser's device picker and loads the URL on the display the user chooses - it needs a user gesture. From then on the connection is an ordinary two-way channel: text or bytes in either direction. Keep the connection id, because it is the only way back in after the page navigates away.

@inject Bit.Butil.Presentation presentation

@* Start opens the browser's device picker, so it needs a real user gesture. *@
<button @onclick="Start">Present</button>

@code {
    private PresentationConnectionHandle? _connection;

    private async Task Start()
    {
        _connection = await presentation.Start(["/present"],
            onMessage: message => Console.WriteLine(message.Text),
            onStateChange: state => Console.WriteLine(state));

        await _connection!.Send("{\"slide\":3}");

        // Keep it: this is the only way back in after this page navigates away or reloads.
        var savedId = _connection.ConnectionId;
    }
}
Live sample
Presentation URL
Message
connection output
Results will appear here when you interact with the samples.

Letting go, coming back, and ending it

Reconnect / Close / Terminate

Closing lets go of the connection while the presentation keeps running on the other screen, which is what makes Reconnect possible - no picker, no gesture, so it works on page load. Terminate is the one that actually closes the page on the second screen.

Razor
@inject Bit.Butil.Presentation presentation

@code {
    private string? savedId;   // connection.ConnectionId, kept from Start
    private PresentationConnectionHandle? connection;

    private async Task LetGo()
    {
        savedId = connection!.ConnectionId;

        // The second screen keeps running - which is what makes Reconnect possible.
        await connection.Close();
        connection = null;
    }

    // No picker and no gesture, so this works on page load - a controller that reloaded finds its
    // own presentation again by id.
    private async Task ComeBack()
    {
        connection = await presentation.Reconnect(["/present"], savedId!,
            onMessage: message => { /* ... */ });
    }

    // The one that actually closes the page on the second screen.
    private async Task EndIt() => await connection!.Terminate();
}
Live sample
Connection id to reconnect to
lifecycle output
Results will appear here when you interact with the samples.

The receiving half

WatchReceiverConnections / SetDefaultRequest

A page opened on the second screen does not create connections - it finds the controllers that opened it, and can talk back over the same handles. SetDefaultRequest is what makes the browser's own 'cast this page' menu item do something for your app.

@page "/present"
@inject Bit.Butil.Presentation presentation

@code {
    // The page that runs on the second screen. Nothing here starts anything: it collects the
    // controllers that opened it, and can talk back over the same handles.
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender is false) return;

        await presentation.WatchReceiverConnections(
            onConnection: connection => Console.WriteLine($"controller {connection.ConnectionId} attached"),
            onMessage: message => ApplyCommand(message.Text));
    }
}
Live sample
receiver output
Results will appear here when you interact with the samples.
Note:
A presentation outlives its connection Closing a connection is not ending the presentation. That distinction is what lets a controller navigate away, reload, or hand control to another tab and then reconnect by id - and it is why a page that goes away should close rather than terminate, unless the user asked for it to stop.
Warning:
Availability monitoring is optional for the browser An engine is allowed to refuse continuous monitoring - it answers with a null subscription rather than an error. Design for that: show the button, and let Start tell the user when there is nothing to present on.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes PresentationRequest.
IsReceiver
ValueTask<bool> IsReceiver()
True when this page is itself the presentation, opened by a controlling page.
Start
ValueTask<PresentationConnectionHandle?> Start(string[] urls, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)
Opens the device picker and loads the URL on the chosen display. Needs a user gesture.
Reconnect
ValueTask<PresentationConnectionHandle?> Reconnect(string[] urls, string presentationId, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)
Rejoins a presentation that is still running. No picker, no gesture.
SetDefaultRequest
ValueTask<bool> SetDefaultRequest(string[] urls)
The request the browser's own 'cast this page' menu item starts.
WatchAvailability
ValueTask<ButilSubscription?> WatchAvailability(string[] urls, Action<bool> handler)
Whether any display could present these URLs, now and on every change.
WatchReceiverConnections
ValueTask<bool> WatchReceiverConnections(Action<PresentationConnectionHandle> onConnection, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)
The receiving half: the controllers connected to this page, including those already there.
PresentationConnectionHandle.ConnectionId
string ConnectionId
The presentation's own id - what Reconnect needs. Worth persisting.
PresentationConnectionHandle.Url
string Url
The URL the receiver accepted and is showing.
PresentationConnectionHandle.Send
ValueTask<bool> Send(string message)
Sends text over the connection.
PresentationConnectionHandle.Send
ValueTask<bool> Send(byte[] data)
Sends binary data over the connection.
PresentationConnectionHandle.GetState
ValueTask<PresentationConnectionState> GetState()
Connecting, Connected, Closed or Terminated.
PresentationConnectionHandle.Close
ValueTask Close()
Lets go of the connection, leaving the presentation running.
PresentationConnectionHandle.Terminate
ValueTask<bool> Terminate()
Ends the presentation, closing the page on the second screen.
An unhandled error has occurred. Reload 🗙