Skip to content

feat(ui, samples): lobby view - #1320

Open
renefloor wants to merge 52 commits into
feat/participant-tilefrom
feat/lobby-view
Open

feat(ui, samples): lobby view#1320
renefloor wants to merge 52 commits into
feat/participant-tilefrom
feat/lobby-view

Conversation

@renefloor

@renefloor renefloor commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🎯 Goal

Update lobby view and unify sdk/dogfooding.

🛠 Implementation details

Added 3 main options for call controls, with small, medium and large.

The simple buttons are mostly intended for android/ios. The full buttons are meant for web. Regular is something that can be used on both. Simple buttons only have on/off for camera and mic. Full have the same on/off, but also the new input selections. The regular one is a bit in between using the new split button, so getting an option to switch inputs, without the large UI. This PR also added a split between CallControlButton and CallFeatureButton. The controls are for things like mic/camera that should be on/off. The Features are for things like screen sharing and more menu, things that are off by default but are sometimes active.

The LobbyActions have controls and settings. The controls can be shown on the camera feed on wide screens, but are always below the feed if there are more than 3 controls to prevent overlap with the participant label.
The helper constructors like LobbyActions.simple have the option to add more controls or settings.

Screenshots from macos style snapshot tests

simple regular full with extras
stream_lobby_view_simple_light stream_lobby_view_regular_light stream_lobby_view_full_light stream_lobby_view_extras_light

The input selection uses an AnchorMenu on desktop/web, but a bottom sheet on android/ios.
It works the same in the lobby and the call screen

macos iPad iPhone
image image image
image image image
image image image

☑️Contributor Checklist

General

  • Assigned a person / code owner group (required)
  • Thread with the PR link started in a respective Slack channel (#flutter-team) (required)
  • PR is linked to the GitHub issue it resolves

☑️Reviewer Checklist

  • Sample runs & works
  • UI Changes correct (before & after images)
  • Bugs validated (bugfixes)
  • New feature tested and works
  • All code we touched has new or updated Documentation

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: d1d8478b-d2ae-48ee-952b-7627f5ebd39c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.90654% with 172 lines in your changes missing coverage. Please review.
✅ Project coverage is 24.15%. Comparing base (951b910) to head (31155e4).

Files with missing lines Patch % Lines
..._flutter/lib/src/call_screen/lobby_controller.dart 82.11% 27 Missing ⚠️
...ter/lib/src/call_controls/call_control_option.dart 0.00% 16 Missing ⚠️
...zations/stream_video_flutter_localizations_nl.dart 0.00% 14 Missing ⚠️
...l_screen/incoming_call/incoming_call_controls.dart 0.00% 13 Missing ⚠️
...l_screen/outgoing_call/outgoing_call_controls.dart 0.00% 11 Missing ⚠️
.../lib/src/call_screen/media_devices_controller.dart 80.00% 9 Missing ⚠️
...c/call_screen/lobby_actions/lobby_device_menu.dart 80.00% 7 Missing ⚠️
...ter/lib/src/theme/components/lobby_view_theme.dart 50.00% 7 Missing ⚠️
...src/call_controls/controls/flip_camera_option.dart 0.00% 6 Missing ⚠️
..._video_flutter/lib/src/call_screen/lobby_view.dart 93.18% 6 Missing ⚠️
... and 21 more
Additional details and impacted files
@@                    Coverage Diff                    @@
##           feat/participant-tile    #1320      +/-   ##
=========================================================
+ Coverage                  22.29%   24.15%   +1.85%     
=========================================================
  Files                        356      370      +14     
  Lines                      27496    27955     +459     
=========================================================
+ Hits                        6131     6752     +621     
+ Misses                     21365    21203     -162     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@renefloor
renefloor marked this pull request as ready for review September 2, 2026 09:48
@renefloor
renefloor requested a review from a team as a code owner September 2, 2026 09:48
renefloor and others added 25 commits September 4, 2026 16:42
Unify the SDK and dogfooding lobbies behind a single StreamLobbyView that
takes LobbyActions(controls, settings), laid out per the design's
breakpoints (<768 / 768-1279 / >=1280).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five changes to the design, none of which the committed plan accounted for:

- The split button and the device select input now share a
  StreamMediaDevicesController rather than each carrying their own list and
  selection. Its on*Selected hooks let the owner decide what picking a device
  does, so the lobby and a future in-call switcher reuse the same object.
- Menus pick their own presentation: StreamAdaptiveMenuAnchor opens an anchored
  popup on desktop and web and a bottom sheet on Android and iOS, so no call
  site branches on platform.
- CallControlState's five values split in two. CallControlButton keeps
  positive/neutral/negative for mic, camera, accept and decline;
  CallFeatureButton carries a selected flag for screen share, captions,
  recording and the members and chat panels. Figma paints a selected feature
  accent blue and a turned-off control red, which one widget cannot express.
  CallControlOption stays as a deprecated shim with its current API.
- Every LobbyActions preset takes extraControls and extraSettings.
- StreamLobbyView defaults to LobbyActions.simple() at every width. The layout
  breakpoints stay in the SDK; choosing a richer preset for a wider window
  becomes a dogfooding demo.

Also: icons come off Material and resolve from context.streamIcons, and the
golden plan now sweeps each of the three presets across all three breakpoints
rather than snapshotting them once.

Eight phases become ten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lobby lays itself out differently above and below 768px, and nothing in
this repo or in stream_core_flutter had a notion of a breakpoint. The buckets
match the design system's screen sizing, so a layout written against
StreamScreenSize lines up with the Figma variant of the same name.

Measured off MediaQuery.sizeOf rather than the widget's own constraints: a
breakpoint describes the window, and a lobby embedded in a narrow column on a
desktop should not rearrange itself for a phone. That is complementary to the
existing device_segmentation.dart, which answers the platform question
instead — both stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CallControlState carried five values that meant three different things: a tone
(on/off/positive/negative), a selection (positive doing double duty for "screen
share is live"), and an error condition (disabled, which was really destructive
plus a badge and had nothing to do with the button being unpressable).

Figma has two components here, and they paint differently — a turned-off
control is destructive red, a selected feature is accent blue — so one widget
with a union state cannot express both without lying about one of them:

- CallControlButton keeps the three tones, now positive/neutral/negative, for
  the microphone and camera toggles, answering, declining and leaving.
- CallFeatureButton carries a selected flag for a feature that is off by
  default and prominent when on: screen sharing, closed captions, recording,
  and the members and chat panels.

showErrorBadge becomes an orthogonal flag on both, so a control can be red,
badged and still tappable. A button that cannot be used takes a null onPressed.

CallControlOption is restored to the shape it has in the last release — the
per-instance colours, shape, elevation and padding, drawing an ElevatedButton
off StreamCallControlsTheme — and deprecated. Code written against the released
SDK keeps compiling and keeps looking the way it did. It is a manual migration
rather than a dart fix, since neither replacement takes those colours.

Icons come off Material and resolve from context.streamIcons, which also lets
this package's own three-icon StreamIcons class and its bundled font go. That
in turn frees the barrel to stop hiding core's StreamIcons, so context.streamIcons
finally has a nameable type. The one exception is the livestream fullscreen
toggle: its cross-fade needs two glyphs and core ships only fullscreenFill.

The dead colour parameters on the toggle widgets are removed with fix_data
transforms — they stopped having any effect when those widgets moved onto
StreamButton, so dropping them changes nothing at runtime.

Also fixes four analyzer warnings that were already failing on the branch, in
dogfooding's home and lobby screens.

The committed CI goldens for StreamCallContent still show the old icons and
need the update_goldens workflow dispatched from this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A popup pinned to a 32px caret is awkward to hit with a thumb and out of place
next to the rest of a phone's chrome, so the lobby's device pickers need a
bottom sheet on Android and iOS and an anchored menu everywhere else. Both the
split button's caret and the select input need that choice, and so will the
participants list, so it is one component rather than a branch inside each.

Deliberately generic over "sections of single-choice options": the presentation
follows the platform and the call site never branches. The builder receives a
StreamMenuHandle — isOpen, open, close, toggle — so the same builder drives
both, and a caret rotates off isOpen without knowing which one it got.

The platform is read off the ambient theme rather than a platform detector, so
a test gets the presentation it asks for, and a mobile browser gets the sheet.

close() is guarded on isOpen because the sheet branch closes by popping a
route: called while nothing is open it would pop the host screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LobbyActions hands the view a plain list of widgets, so a lobby action cannot
be given callbacks through its constructor. Everything that was spread across
_StreamLobbyVideoState, _LobbyScreenState and _LobbyDeviceControlsState moves
into two objects the actions read from the tree through StreamLobbyScope.

StreamMediaDevicesController is the shared half. It owns which device is
picked; its owner supplies what picking it does, through the on*Selected
hooks. That is what makes the split button and the select input
interchangeable — both call selectVideoInput and neither knows whether the
effect is a lobby preview restarting or a call switching camera.

StreamLobbyController owns the tracks, the participants already in the call,
and the last failure per track for the error badge. Two things the SDK lobby
got wrong come out in the wash: connectOptions now carries the picked devices,
which it used to drop on the floor, and the tracks go over as
TrackOption.provided, so joining continues on the microphone and camera the
user was previewing rather than opening a second pair. dispose only stops
tracks that were not handed over, or it would kill the microphone the user
just joined with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven widgets that each read StreamLobbyScope and take nothing in their
constructor, so LobbyActions can hand them to the view as a plain list: the
microphone and camera toggles, the same two as split buttons with a caret that
picks the device, the two select inputs for a settings row, and the
participants control.

The split button and the select input build their sections from one
StreamMediaDevicesController through a shared LobbyDeviceMenus extension, so a
lobby showing both cannot disagree with itself — picking a microphone on the
caret updates the field, and the other way round. Neither knows whether it
will open a popup or a sheet; StreamAdaptiveMenuAnchor decides.

Writing the test for that turned up a real bug: StreamLobbyScope listens to
the lobby controller, not to the device controller it owns, so a device list
arriving never rebuilt the pickers. The lobby controller now forwards those
notifications.

The pickers stay visible but disabled until their device has been opened once.
The platform only names devices after getUserMedia has succeeded, and hiding
the field would shift the layout the moment permission is granted.

Lobby strings move into the localizations, English and Dutch. That needed
`flutter: generate: true` in the pubspec, without which `melos run gen-l10n`
refuses to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lanes: controls are the icon buttons that turn the microphone and camera
on and off, settings are the fields that pick which device to use. A lane
whose list is empty is not drawn, so a preset without device pickers leaves no
gap where the row would have been.

Every preset takes extraControls and extraSettings, which is what lets a host
use a preset "with maybe an extra button" rather than respelling the whole
list. .simple's settings lane is empty by default but no longer forced empty.

The presets cannot be const because they splice the extras; only .custom is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The view now installs a StreamLobbyController as a StreamLobbyScope and lays
out whatever LobbyActions gives it. StreamLobbyVideo is absorbed: its track
handling went to the controller in the previous phase, its rendering becomes
the private preview here.

Layout follows the window's StreamScreenSize — controls below the preview
under 768px, overlaid on it above, and a 370x264 aspect against a fixed
640x360 — but which actions are shown does not: actions defaults to
LobbyActions.simple() at every width. Choosing a richer preset for a roomier
window is the host's call, demonstrated in dogfooding rather than baked in.

The preview block is capped at the tile's width so the settings fields line up
under it rather than running the width of the window, and the join button is
full width on a phone and a fixed 400 where there is room.

No Scaffold or AppBar of its own, so it embeds in a screen that already has
one — which is also what lets it be snapshotted under the unbounded
constraints a golden test uses. onCloseTap goes with the app bar; the example
app now supplies both.

The participants card is replaced by StreamLobbyParticipantsControl, so
StreamLobbyParticipantsView goes too. The hardcoded English moves into the
localizations.

onJoinCallPressed returns whether the join happened, and may be async. The
lobby hands its tracks over before calling it — a host usually navigates from
inside it, and the lobby is disposed on the way out — so a false return is how
it takes them back and carries on previewing. A call that could not be created,
or a key the platform cannot use, no longer strands the user on an empty
screen. joinEnabled and footer come with it: a lobby can ask for something the
SDK knows nothing about and refuse to join until it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lobby theme was the last legacy one on this screen: non-nullable fields
with hardcoded defaults, a hand-rolled copyWith/lerp/==/merge, and a plain
InheritedWidget whose of() replaced rather than merged. It now follows the
@themeGen pattern the other component themes use — one nullable
StreamLobbyViewStyle whose every property means "no override", against a
_StreamLobbyViewStyleDefaults in the widget deriving the rest from StreamTheme.

None of the old properties survive, because none of them describe the widget
any more: the lobby paints no background now that it builds no Scaffold, the
participants card that participantListHeight sized is gone, and
optionOffBackgroundColor and optionOffIconColor were already read by nothing.

Dogfooding drops its hand-rolled lobby and its LobbyDeviceControls — 240 and
286 lines — for StreamLobbyView with two extra controls. Picking a preset by
window width stays, but as a demo of what a host can do rather than something
the SDK does. On Android and iOS the split buttons and select inputs open
bottom sheets with nothing at this call site saying so.

The encryption settings move into the lobby's footer, and the deferred call
creation with them: the switch decides how getOrCreate is called, so it has to
stay live until the moment before, which is now the join callback. Returning
false from it leaves the lobby up with its preview running, so a call that
could not be created or a key the platform cannot use no longer strands the
user on an empty screen. LobbyEncryption is rebuilt on the design system —
StreamSwitch, StreamTextInput and the icon set — because the cardBackgroundColor
it borrowed from the lobby theme does not survive the migration.

The blur toggle becomes a CallFeatureButton that reads the camera track from
StreamLobbyScope, so switching camera device reapplies the filter to the new
track — replacing the onCameraTrackSet hook and the ValueKey trick that made
the old preview restart.

The previous commit deleted StreamLobbyVideo without updating dogfooding,
which left it uncompilable; this restores a green workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One golden group per LobbyActions preset, each across all three breakpoints,
in both brightnesses. The sweep is the point: the preset decides which widgets
exist, the width decides where the control row sits and how big the preview
is, and the two must stay independent — a simple lobby at 1440 still overlays
its controls, a full lobby at 375 still stacks them below and keeps its
settings row.

StreamScreenSize reads MediaQuery.sizeOf, so each case wraps the subject in a
MediaQuery of the target size; sizing the alchemist surface alone would leave
every case reporting the same breakpoint and snapshotting identically.

Driven by a StreamLobbyController over mocks, so the goldens need no live
call, no device notifier and no camera.

The committed CI goldens are not updated here: they are generated on the Linux
host CI compares against, by dispatching the update_goldens workflow from this
branch. StreamCallContent's two will change as well, since the call controls
it draws now use design-system icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A narrow desktop window was getting the `regular` preset — split buttons and
no settings row — because dogfooding keyed the choice on StreamScreenSize. The
design wants plain toggles below the feed plus the two device fields there,
which is `full` at the small breakpoint.

Width was the wrong signal. A phone has no room for a settings row at any
size, so its device choice belongs on the toggles' carets; anything with a
pointer gets the fields however narrow the window is. That is what
isMobileDevice answers, and it is exactly the complement to StreamScreenSize
the two were kept separate for.

The layout still follows the width — control row on the preview above 768px,
below it under — but that is StreamLobbyView's business and needs nothing from
the call site, which is why the SDK side needed no change: `full @ 375`
already rendered correctly.

The golden sweep gains a 500px column standing for the narrow desktop window,
so the case the design calls out is snapshotted rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getOrCreate returns a snapshot of the call session, and the coordinator event
subscription is already live while that request is in flight. A join that is
already reflected in the snapshot therefore still arrives as a
ParticipantJoined event, and the lobby appended it blindly — so one person on
one other device showed up as two rows and a badge of 2.

Participants are now upserted by userSessionId. That is the right identity:
someone on a phone and a laptop is genuinely two participants and belongs in
the list twice, which is why deduplicating by user id would be wrong. It also
matches the leave handler, which already removed by session id.

A join event for the local user is ignored too, matching the snapshot, which
always filtered them out.

The sort's fallback for a missing joinedAt was a fresh DateTime.now() per
element, evaluated repeatedly by sortedBy; it is one captured timestamp now.

Carried over verbatim from the old StreamLobbyView, so this predates the
rewrite rather than coming out of it. Both regression tests fail against the
old handler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StreamTextInput defaults textCapitalization to .sentences, which is right for
prose but wrong for an identifier: iOS capitalized the first letter of a typed
call id, so "test123" was sent as "Test123" and joined a different call — or
none at all. The same applies to the user ids typed for a direct call, which
are looked up verbatim.

Left alone deliberately: the login field, whose value is the display name as
well as the seed for the user id, and the feedback field, which is prose.

Unrelated to the lobby work, but it is a one-line fix in the same app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The anchored menu was content-sized, so under a 590px device field it drew a
~250px popup that read as belonging to something else.

StreamAdaptiveMenuAnchor gains matchAnchorWidth, off by default because a menu
is normally as wide as its content — a popup hanging off a 32px caret should
not be 32px wide. The lobby's two device fields turn it on; the split buttons
do not.

The width comes from the anchor's own incoming constraints, measured with a
LayoutBuilder, because the panel's constraints are a property of the anchor
widget and are fixed before MenuAnchor's builder runs. StreamContextMenu is an
IntrinsicWidth, so a tight width overrides what its content asks for —
including the 200px minimum a menu row carries, which would otherwise overflow
a field narrower than that. Covered by a test at 160px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A StreamListTile's selected background is a rounded rectangle, and running it
flush into the sheet's sides read as a rendering mistake rather than a
highlight. Rows in the device sheets and in the lobby's participants sheet now
carry a spacing.xxs margin on both sides, matching the iOS layout sheet in the
design system.

The section heading takes spacing.xs rather than spacing.xxs to compensate for
insetting itself less than a list tile does, so heading text still lines up
with where a row's content starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The preview was a hand-rolled copy of the tile, and had already drifted: it
filled with backgroundSurface where a tile uses backgroundSurfaceSubtle, and
drew a plain default-sized avatar where a tile draws an xxl one with a ring.
Aligning the two by hand would only have delayed the next divergence.

It is now the tile itself. What made that look impossible was the video: the
lobby's camera track is warmed up locally and never registered with the call,
so StreamParticipantVideo's lookup by session id finds nothing. But
videoRendererBuilder short-circuits that path entirely, so the tile never
reaches for the call's track registry, and CallParticipantState is
constructible from what the controller already knows — exposed as
localParticipant, with the mute state the label and outline read from.

Speaker border, connection quality and reactions are switched off; none of
them mean anything before the call is joined. The accent ring stays the
lobby's own chrome, painted around the tile, because a tile showing video
deliberately draws no outline.

StreamLobbyViewStyle loses previewBackgroundColor, participantLabelInset and
the placeholder avatar theme — the tile owns all three now — and gains
previewTileStyle for making the preview differ from the call's tiles.

The lobby goldens needed tight two-axis scenario constraints: the tile sheds
chrome to fit through a LayoutBuilder, and alchemist lays scenarios out in a
Table, which asks for intrinsics a LayoutBuilder cannot give.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blue ring in the design is the participant tile's speaking outline, not
chrome the lobby owns. I had read it as an always-on accent border, which
meant painting a ring the tile would never have drawn and switching off the
real one.

The ring goes, along with previewBorderColor and previewBorderWidth, and
showSpeakerBorder is left at its default. Not speaking now renders exactly the
design's resting state: the tile's subtle surface and hairline border.

The outline never triggers yet, because the in-call speaking state comes from
the SFU and there is no local microphone level before joining — FLU-714. When
there is one, the preview lights up with no change here.

Connection quality stays off for the same reason: the design shows it, but
nothing reports it pre-join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlaid row is centred on a preview whose bottom-start corner is already
occupied by the participant label, so it only has room for a few buttons.
Dogfooding on a tablet shows four — the regular preset plus the participants
and blur controls — and they ran straight into the label.

Past maxOverlaidControls, 3 by default, the row sits below the preview
whatever the screen size. Two controls still float over it, which is what the
design shows.

A count rather than a measurement: the collision depends on the label's width,
which depends on the participant's name, and a layout that moved the controls
when someone's name got longer would be worse than a fixed rule.

Covered by a golden group mirroring the tablet case, and by assertions that a
long row clears the preview's bottom edge while a short one does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourteen fields, most of them restating a token the widget could read
directly. previewBorderRadius was the clearest case: its default was
BorderRadius.all(radius.xxl), exactly what StreamParticipantTile already
defaults to, and the lobby passed it into the tile unconditionally — so an app
that themed its tiles' radius found the lobby quietly ignoring it. The same
bug the background had, one field along.

Gone: previewBorderRadius, and the seven spacings and two text styles that
were only ever spacing.xs/sm/md/xxl and textTheme.headingMd/bodyDefault. An
app that wants different gaps changes StreamSpacing; one that wants a
different heading passes `title:`, which the constructor already takes.

What is left is what the lobby actually owns: previewTileStyle,
smallPreviewAspectRatio, largePreviewSize, maxOverlaidControls and
joinButtonWidth.

Covered by a test that a StreamParticipantTileTheme set above the lobby
reaches the preview — the property that makes using the tile worth anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MenuAnchor clips its panel to the panel's own bounds by default, and
StreamContextMenu's Material draws its shadow outside them — so the shadow was
cut off flush with the menu's edge instead of fading out, which reads as a
hard line along the bottom where that shadow is heaviest.

Confirmed rather than guessed: at elevation 16 the rendering was identical to
elevation 3, because the extra shadow was clipped too. With Clip.none both
render as they should. Nothing needed the panel's clip — the menu draws its
own surface and clips its own content.

Both anchors also take an elevation now, defaulting to
StreamContextMenuTheme's, so a call site can flatten a menu to 0 without a
theme if that is what the design wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Using StreamParticipantTile for the preview meant it also picked up whatever
an app registers as its participantTile component — which for dogfooding adds
an overflow menu to every tile, so the lobby offered to pin someone who had
not joined anything.

Suppressed through the tile's style rather than its props, because such a
builder rewrites the props and would undo it there. Ordered so previewTileStyle
still wins, for an app that really does want the button.

The test registers a component builder the way dogfooding does, and fails
without the fix rather than for want of a matching icon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing reports a local audio level before joining, so the indicator sat in
the preview's name pill permanently idle — three dots that never move.

The lobby turns it off through the tile's labelStyle, using the
showAudioIndicator the label style carries. In-call tiles are untouched: the
label still draws it, blue while the participant speaks.

Like the speaking outline, this is waiting on FLU-714 rather than being wrong
in principle — a local mic meter would give the indicator something to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A camera that was refused permission, or that does not exist, was drawn in the
same red state as one the user had deliberately muted — so a permission
problem looked like a choice they had made. On an iPad simulator, where there
is no camera at all, that is all you saw.

Per the design it is now a disabled control with an error badge. The split
buttons needed both halves nulled, because a split button only takes on its
disabled surface once neither half can be pressed — and with no device there
is nothing for the caret to offer. They had no badge at all before, which is
why the simulator showed plain red.

Unavailable means the last attempt to open the device failed, or the platform
has stopped reporting one since permission was granted — the second covers a
camera unplugged mid-session, which no failed attempt would catch.

Joining is deliberately still possible, as the design says, and asserted
rather than left to the golden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design's icon above "Set up your call" was in dogfooding's own lobby and
was lost when that screen moved onto StreamLobbyView, which drew a bare title
and subtitle. It is back, and in the SDK rather than in the sample, so every
lobby gets it.

The heading is the piece an app is most likely to want its own version of — a
logo, a call name, a countdown — so it is a component with a lobbyHeader
builder rather than only the two widget parameters. Those still work and are
passed through to it, and both still fall back to the localized defaults.

The golden scenarios needed more height: the header is about 40px taller than
the bare title it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
renefloor and others added 26 commits September 4, 2026 17:18
StreamLobbyView.footer was a plain widget parameter, so a footer had to be
passed at every call site. It goes through StreamLobbyFooter now, with a
lobbyFooter builder alongside the header's, which is also what lets an app put
something in every lobby's footer — a consent notice, an encryption key field —
without touching the call sites.

The slot is only built when there is something to build: a footer from the call
site, or a registered builder. The body's Column spaces its children out, so an
empty slot would leave a gap between the controls and the join button in every
lobby that has no footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design puts a single line under the icon, so the header no longer draws a
subtitle of its own — it draws one only where a call site passes it. The
localized string it used to fall back to is removed along with it, in both
English and Dutch, rather than left behind unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stream_video_filters drives the platform's own segmentation pipeline, and only
Android and iOS ship one — elsewhere the filter is accepted and then does
nothing, so the lobby offered a blur toggle that silently did nothing on
macOS, Windows, Linux and web.

AppFeature lists what the app offers against the platforms that can run it,
keyed on PlatformType so a feature reads as a set of platforms rather than a
chain of negated checks. Both call sites ask it: the lobby's extra control and
the in-call settings menu's background effects entry.

It stays in the sample rather than moving into the SDK because the decision is
the app's: which features to offer, and whether an unavailable one is hidden,
disabled or explained.

One knock-on worth expecting: with blur gone, a desktop lobby has three
controls rather than four, so the row now floats over the preview instead of
sitting below it. That is the design's rule for a short row, not a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control called showStreamSheet directly, so a desktop lobby got a bottom
sheet sliding up over the whole window for a list of names.

StreamAdaptiveMenuAnchor only modelled a choice: every row carried a radio
indicator and a callback, and every section a heading. A row is now a choice
only when selected is set and pickable only when onSelected is, it can bring
its own leading widget, and a section's heading is optional. The device menus
set all three and are unchanged; the participant list sets none of them and
gets avatars and names instead.

That leaves one place deciding how a menu is presented, which was the point of
the component — the participant list was the one thing still deciding for
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design's menu row is sized for a 16px icon beside a caption. An avatar is
40, so in a 40px row it touched the top and bottom edges and sat 12px from the
panel's side — the list read as a stack of avatars rather than as rows.

StreamContextMenuAnchor takes an actionStyle merged over that design row, and
StreamAdaptiveMenuAnchor passes one through as menuItemStyle. The participants
list asks for a 56px row with a 12px inset, which are the sheet's own numbers:
a name now sits the same distance from the edge whichever presentation opens.

Measured rather than eyeballed — the avatar had exactly zero vertical padding
before, and has 8 above and below now. The device menus pass no override and
keep the 32px design row, which is asserted so a future change to one does not
quietly drag the other along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lobby

Neither was ever lobby-specific — they read StreamLobbyScope, which is what
tied them there. They now take what they need: a StreamMediaDevicesController
and an enabled state for the split buttons, the people to badge and list for
the participants control. StreamMediaDevicesController.forCall wires one to a
call's own device setters, so a device picker behaves the same either side of
joining.

StreamParticipantsControl takes an optional onTap that replaces the built-in
list. A call screen usually has a panel of its own with more in it than names,
and it stays pressable with nobody to list — an empty call still has a panel
worth opening — where without onTap it disables itself.

The StreamLobby* widgets stay as thin wrappers reading the scope, so a preset
can list them without wiring anything up, and the lobby is unchanged.

Dogfooding's call screen uses all of it: split buttons in place of the plain
toggles, so a device can be changed mid-call without opening the settings
menu, and the participants control with onTap opening its own screen. That
also retires its hand-rolled badged button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a simulator with no camera made obvious.

The device menu always led with "System default", so with no devices at all
the caret opened a one-entry menu that could not pick anything — which is why
an iPad simulator appeared to offer a camera it could not enable. A section is
now built only where the platform names a device, and with nothing left to
offer the caret and the select field are disabled.

The caret also always pointed down when closed, which is wrong wherever the
menu opens upwards — a control bar along the bottom of a call, where
MenuAnchor flips the menu for want of room below. StreamMenuDirection says
which way the anchor expects to open: closed, the caret points where the menu
will appear; open, it points back at the anchor. It also picks which side the
menu's offset clears. Dogfooding's call bar asks for up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lobby computes whether a device is unavailable and hands it to the split
button; the call screen never did, so it only knew "nothing to pick" and
disabled the caret alone — a red camera pill with a greyed caret, where the
lobby showed the whole control disabled and badged.

The call now passes it too. What it cannot reuse is the lobby's guard, which
waits until the device has actually opened: mid-call there is no such moment.
StreamMediaDevicesController.hasEnumerated is the weaker check it can use —
enough to tell "the platform found none" from "the platform has not answered
yet", which without it would flash an error badge as the call opens.

The two guards are deliberately different and the lobby's says why, since the
obvious tidy-up is to make it the weaker one and that would misreport a
camera that exists but has not been granted yet.

The buttons also listen to the device controller now: which devices exist
arrives on that stream, not in call state, so they would not otherwise have
rebuilt when it changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every `await` in StreamLobbyController outlives the widget that owns it,
and none of them checked whether the controller was still alive.

`_applyCallDefaults` deferred through a bare `Future.delayed` that nothing
could cancel, so leaving the lobby inside the same frame still ran it. Both
device openers then notified a disposed ChangeNotifier — which throws in
debug and is an unhandled async error in release — and, worse, assigned a
live track to a controller whose dispose had already run its `stop()`. The
camera stayed open with nothing left holding a reference to it.

Tracks are now stopped when they arrive after disposal, since nothing is
left to hand them to a call; the defaults run from a cancellable timer; and
notifications go through a guard. The hand-over path is deliberately
untouched: a track marked as the call's is still left running, and
`tracksHandedOver` exposes that so it can be asserted.

Opening is also latched, so tapping a control twice while the permission
prompt is up no longer creates a second track and orphans the first, and
track failures are logged at error with their stack trace — the one thing
that separates a refused permission from a device another app is holding.

The openers are injectable behind @VisibleForTesting because
RtcLocalTrack's factories are static, which is what left every path
downstream of an open device untested, hand-over included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`microphoneUnavailable` answered two different questions with one flag:
how the control should look, and whether it could be pressed. Since
`_microphoneError` is only cleared by a successful open, and the button was
disabled precisely while that error was set, the only code that could clear
the failure was the only code the button would not run. A camera another
app was holding, or a permission refused and then granted in system
settings, left the control dead for the rest of the lobby's life.

The questions are now separate. `microphoneUnavailable` / `cameraUnavailable`
still drive the error badge; the new `microphoneMissing` / `cameraMissing`
— nothing for the platform to open — are what disable the button. So a
failure stays badged but live, and pressing again is what clears it.

`StreamMicrophoneSplitButton.unavailable` and its camera counterpart lose
their disabling side effect for the same reason: badging is appearance,
pressability is the caller's to state through `onPressed`, which is how
`CallControlButton` already worked. Dogfooding's in-call bar passes null
where it has no device.

Also stops treating a live track as a missing device. With permission
granted and an empty enumeration, `microphoneUnavailable` badged a
microphone that was streaming and took away the user's only way to mute
it. A track the lobby is holding is proof enough that its device exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StreamMediaDevicesController published a selection and then hoped. Four
ways it could end up describing something that was not true:

`_handleDeviceChange` replaced the three device lists and left the three
selections alone, so unplugging a chosen webcam left the selection naming
it. The menu then drew no selected row — the id matched nothing and
`selected == null` was false — the select field kept the old label, and the
dead id went into `call.join`. Selections are now re-read from the new
list, falling back to the system default when the device is gone, which
also refreshes a label that only arrived with permission.

`forCall`'s three hooks were `if (device != null) await call.setX(device)`,
so picking "System default" updated the selection, notified, moved the
radio button and never told the call. Call has no setter for it, so the row
is left out instead: `supportsSystemDefault` says whether a null selection
can be applied at all.

Those same hooks awaited a `Result<None>` and discarded it, in a file with
no logger, so a moderation block or a device another process grabbed left
the menu showing a camera the call was not using and nothing in the log.
The Result is folded now, and a hook may throw to reject a selection, which
puts the previous one back.

And the first enumeration was fired with `unawaited` and its Result
dropped. On the throwing path the notifier never emits, so `hasEnumerated`
stayed false forever, every picker was inert, and the select field's
tooltip blamed permissions. `enumerationError` records it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StreamAdaptiveMenuAnchor passed `enabled: option.onSelected != null` to
both presentations, reading the design system's `enabled` flag as "is this
interactive". It is not: `enabled: false` is the *unavailable* look, and
both StreamContextMenuAction and StreamListTile resolve their label to
`textDisabled` under it.

The only option built without an `onSelected` is a participant in
StreamParticipantsControl's list, so every person already in the call was
drawn as though they could not be reached.

A null `onSelected` still leaves the row inert, and StreamListTile still
gives it a non-interactive cursor — that check is separate from the one
that picks the text colour. Only the greying is gone.

Asserted in widget tests rather than goldens: the CI capture path drops
anything painted into an Overlay, so a menu snapshots blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_applyCallDefaults` deferred by `Duration.zero` with a comment saying the
settings "arrive with its state, which is not populated yet when the
controller is constructed". One event-loop turn does not wait for a network
round trip: `CallState` is constructed with `const CallSettings()`, whose
`micDefaultOn` and `cameraDefaultOn` are both true, and the real values
only land from the call's metadata. So a call configured to start muted had
its microphone opened regardless, and nothing said so.

The defaults now run from the fetch's own result, where the settings
actually are. When the fetch fails they fall back to the call state, which
keeps a lobby that cannot reach the coordinator working as it does today
rather than opening nothing.

That failure is also no longer just a log line. `fetchError` records it, so
a host can tell an empty call from an unreachable one — the participant
list looks identical in both — and the stack trace is logged alongside.

The unavailable-device golden shifts by 0.07%: since the previous commit
its badged control is enabled rather than disabled. The committed CI
goldens need regenerating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places in the sample committed to a device change before knowing it
had happened.

The in-call microphone and camera buttons called `setMicrophoneEnabled` /
`setCameraEnabled` and dropped the `Result`. Their visual state comes from
the call's participant state, which does not change on failure, so a
viewer without `sendVideo` tapped the camera and watched nothing happen.
Two of the three failure exits in `setCameraEnabled` log nothing either, so
there was no trace anywhere. Both now fold the Result and raise a snackbar.

`_BlurToggle` applied its filter from inside `build()`, writing `_appliedTo`
before awaiting — so a build that was never committed still recorded the
track as filtered — and left the future unawaited, making any platform
throw an unhandled async error while the button went on claiming blur over
an unblurred preview, with the track already marked as done so it never
retried. It runs from `didChangeDependencies` now, catches, and puts the
toggle back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unreleased section contained three pairs of entries that contradicted
each other, because this branch superseded work done earlier in the same
cycle. A reader of the eventual release notes would have got both halves:

- a Fixed entry advertising documentation on `StreamLobbyVideo`, which the
  Breaking section in the same release removes, plus a Changed entry about
  how that widget sizes its preview;
- an entry saying `CallControlOption` "now takes a `state`" of
  `CallControlState.on` / `off` / `disabled` — values that no longer exist,
  so following it is a compile error — against the entry saying the class
  has been restored to its released shape;
- an entry saying the toggle colour parameters are "still accepted but no
  longer have any effect", against the one saying they are removed.

Also: `StreamLobbyView.subtitle` claimed a localized default it lost when
the default subtitle was dropped; the class doc described the layout purely
by width, omitting that a row longer than `maxOverlaidControls` drops below
the preview at any width; `lobby_device_menu` claimed iOS reports no output
devices, which `StreamMediaDevicesController.audioOutputs` correctly
contradicts thirty lines away; a published snippet used `Icons.expand_more`,
the pattern this branch removes everywhere else; the participants control
overstated its insets as identical across presentations; and one comment
was repeated five lines below itself.

LOBBY_PLAN.md goes with them, as PARTICIPANT_TILE_PLAN.md did on the base
commit once its checklist was ticked. It was already describing a present
that no longer exists and linking to four deleted files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other public type here is Stream-prefixed — StreamLobbyView,
StreamLobbyController, StreamLobbyScope, StreamLobbyHeader, and each of the
action widgets. LobbyActions was not, and it is the type that appears in
every integrator's StreamLobbyView call. Renaming it once it has shipped
costs a deprecation and a fix_data transform; renaming it now is free.

Its lanes are also unmodifiable now wherever the class builds them. The
presets spread their built-ins into a growable list and handed it straight
out, so `StreamLobbyActions.simple().controls.add(...)` mutated a type
annotated @immutable. `custom` deliberately keeps passing through what it
was given, which is what lets it stay const and so serve as a default
parameter value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The enum's own doc calls it a tone, and its values are `positive`,
`neutral` and `negative` — none of which is a state the button is in. The
name invited exactly the confusion the CallControlButton/CallFeatureButton
split exists to prevent: whether a feature is switched on is
`CallFeatureButton.selected`, not this.

The parameter goes with it, so `CallControlButton(tone: .negative)` reads
as what it is. Both are unreleased, so this needs no deprecation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`hasMicrophonePermission` and `hasCameraPermission` are set in the success
path of opening a track, and their own doc says "whether the microphone has
been opened at least once". Neither asks the platform what it would grant.
An integrator reading the name and wiring a permission prompt off it would
have had it backwards — the flag can only become true after the prompt has
already been answered.

`hasOpenedMicrophone` and `hasOpenedCamera` say what they mean, and the doc
now states outright that this is not a permission check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`StreamLobbyViewStyle.largePreviewSize` applied at
`StreamScreenSize.smallMaxWidth` and above, which is `medium` *and*
`large`, while `StreamScreenSize.large` means something narrower. Two
public types, one word, two meanings — and a `medium` variant, if one is
ever designed, would have forced the rename anyway.

They are `compactPreviewAspectRatio` and `expandedPreviewSize` now, named
for the layout they produce rather than for a breakpoint bucket.

`expandedPreviewSize`'s doc also stops calling itself a size: both
`_LobbyPreview` and `_LobbyBody` use it as a maxWidth plus an aspect, so a
window past the breakpoint but narrower than 640 gets a smaller preview of
the same shape. And `maxOverlaidControls` now says it lerps as a step, so
nobody is surprised when a theme animation jumps the control row from
overlaid to below partway through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nstruction

`call` was required and `controller` optional, and when both were given the
call was ignored — it only ever fed the controller the view would have
built. So this compiled:

    StreamLobbyView(call: a, controller: StreamLobbyController(call: b))

and the preview, the participants and the connect options all came from
`b` while the host joined `a`.

Two constructors instead, so the contradiction cannot be written:
`StreamLobbyView({required call})` owns and disposes its controller, and
`StreamLobbyView.withController({required controller})` takes one the
caller keeps and reads the call off it. `call` stays a non-null getter,
resolved from whichever was supplied.

There was also no `didUpdateWidget`, so a changed `call` was ignored by an
already-built owned controller and a controller arriving later left the
owned one running until the widget was disposed. Both now drop it.

The owned-controller path had no test at all — every existing case supplies
a controller — so it was never constructed or disposed in CI. It is now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things that would each need a deprecation once released.

`CallButtonBadge` was exported. Its own doc explains it exists so the badge
lands in the same place on both buttons — an implementation detail, and
exporting it both fixed its shape forever and handed integrators a way to
badge things inconsistently. It is library-private now.

`CallControlButton` wrapped every tone in a `StreamButtonTheme` pinning
`primary.solid` to `accentSuccess`, including `neutral` and `negative`,
which use the secondary and destructive styles and never read it. An app
theming its own primary solid buttons could not theme this one. Only
`positive` gets the override now.

`StreamMenuHandle` is an `abstract interface class`, which is what it meant.

`sections.every((s) => s.options.isEmpty)` was written out at two call
sites, because "has this menu anything to offer" is something every caller
needs and the type did not answer. It is `hasNoOptions` on the iterable.
Relatedly, a section with a heading and no rows drew a label over nothing
in both presentations — the anchor drops it rather than making callers
filter.

And `StreamLobbyScope.of` asserted and then force-unwrapped, so in release
— where the assert is gone — it failed as a bare null-check with none of
the explanation the assert carried. It throws a FlutterError, naming the
widget that asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several doc comments explained why the previous shape was wrong rather than
what the current one does — that the call could disagree with the
controller, that a growable list made @immutable a false claim, that
`largePreviewSize` meant two things, that reading `call.state` early only
ever saw the defaults. That story belongs in the changelog and the commits,
both of which already carry it; a reader of the API needs the behaviour.

Trimmed to the behaviour, keeping the parts that warn about a live trap —
that `enabled: false` is the design system's unavailable look, that every
await here outlives the widget, that the openers are injectable because
RtcLocalTrack's factories are static.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StreamLobbyController fetched with getOrCreate, so the call came into
existence the moment the lobby was shown — inherited from the lobby view this
branch replaced, where the same call sat in build().

That is a decision the lobby is not entitled to make. What a call is created
with is fixed at creation, its encryption mode above all, and dogfooding
deliberately defers creation to the join press so the encryption switch is
still live when getOrCreate is finally called. It never was: the controller
had already created the call, unencrypted, before the switch was drawn, and
the create-on-join then returned that existing call. Starting an encrypted
call produced a plain one. Joining an existing encrypted call was unaffected,
because there the fetch is only ever a fetch.

The controller reads the call now. get() runs the same _performGetOperation
and returns the same metadata, so nothing else changes; a call that does not
exist yet fetches as a failure, which the controller already handles — the
lobby keeps its preview, lists no participants, and reports fetchError.

Creating is the host's, before the lobby or from onJoinCallPressed. The
example's join tab typed an id straight into the lobby and relied on it being
created, so it now says so itself.

CallReceivedData, what Call.get() returns, was not exported, so no caller
could name the type it has to handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant