Presentation
Open one of your own pages on a second display - a projector, a TV, a cast receiver - and keep a two-way message channel to it. The second screen shows something other than a copy of the first.
@inject Bit.Butil.Presentation presentationMDN reference
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.
@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 => { /* ... */ });
}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.
var subscription = await presentation.WatchAvailability(
["/present"],
available => { canPresent = available; StateHasChanged(); });
await subscription!.DisposeAsync(); // monitoring costs batteryStart 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.
Bit.Butil.Presentation presentation
@* Start opens the browser's device picker, so it needs a real user gesture. *@
<button @onclick="Start">Present</button>
{
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;
}
} "/present"
Bit.Butil.Presentation presentation
@* The page the second screen loads. It is an ordinary page of the same app on the same origin -
the URL passed to Start is what the display navigates to. *@
<h1>Slide _slide</h1>
{
private int _slide;
// A receiver page does not create connections; it finds the controllers that opened it.
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 => InvokeAsync(() =>
{
_slide = JsonDocument.Parse(message.Text!).RootElement.GetProperty("slide").GetInt32();
StateHasChanged();
}));
}
}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.
Bit.Butil.Presentation presentation
{
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();
}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.
"/present"
Bit.Butil.Presentation presentation
{
// 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));
}
} Bit.Butil.Presentation presentation
{
// On the controlling page, so the browser's own "cast this page" menu item does something for
// this app rather than being greyed out. It is a standing offer, not a request: no picker opens.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) await presentation.SetDefaultRequest(["/present"]);
}
}API reference
ValueTask<bool> IsSupported()ValueTask<bool> IsReceiver()ValueTask<PresentationConnectionHandle?> Start(string[] urls, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)ValueTask<PresentationConnectionHandle?> Reconnect(string[] urls, string presentationId, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)ValueTask<bool> SetDefaultRequest(string[] urls)ValueTask<ButilSubscription?> WatchAvailability(string[] urls, Action<bool> handler)ValueTask<bool> WatchReceiverConnections(Action<PresentationConnectionHandle> onConnection, Action<PresentationMessage>? onMessage = null, Action<PresentationConnectionState>? onStateChange = null)string ConnectionIdstring UrlValueTask<bool> Send(string message)ValueTask<bool> Send(byte[] data)ValueTask<PresentationConnectionState> GetState()ValueTask Close()ValueTask<bool> Terminate()