feat(ui, samples): lobby view - #1320
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
e981e76 to
4a9ddc5
Compare
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>
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>
dad6156 to
cdfaa71
Compare
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>
🎯 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
LobbyActionshave 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.simplehave the option to add more controls or settings.Screenshots from macos style snapshot tests
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
☑️Contributor Checklist
General
☑️Reviewer Checklist