Skip to content

feat(ui): call controls - #1324

Draft
renefloor wants to merge 16 commits into
feat/lobby-viewfrom
feat/call-controls
Draft

feat(ui): call controls#1324
renefloor wants to merge 16 commits into
feat/lobby-viewfrom
feat/call-controls

Conversation

@renefloor

Copy link
Copy Markdown
Contributor

🎯 Goal

Making new call control bar

🛠 Implementation details

This adds a new responsive call controls bar with no controls by default.

🎨 UI Changes

Screen.Recording.2026-09-03.at.09.49.31.mov

☑️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 3, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.45833% with 13 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (feat/lobby-view@0baa316). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...b/src/theme/components/call_control_bar_theme.dart 53.33% 7 Missing ⚠️
...lutter/lib/src/call_controls/call_control_bar.dart 93.33% 5 Missing ⚠️
...ideo_flutter/lib/src/theme/stream_video_theme.dart 83.33% 1 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                 @@
##             feat/lobby-view    #1324   +/-   ##
==================================================
  Coverage                   ?   24.80%           
==================================================
  Files                      ?      372           
  Lines                      ?    28140           
  Branches                   ?        0           
==================================================
  Hits                       ?     6979           
  Misses                     ?    21161           
  Partials                   ?        0           

☔ 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 and others added 16 commits September 5, 2026 13:56
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>
A row of controls along the bottom of a call, taking one
CallControlBarLayout per screen size and drawing the one the window
calls for. Only `small` is required; `medium` and `large` fall back to
the next smaller layout that was given.

The centre slot is centred in the bar's full width rather than in the
gap between the two sides, so a lopsided leading group leaves it where
it was. The bar owns its chrome — background, top hairline, safe area,
and a docked or floating surface — but no controls: the caller supplies
every one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the hand-rolled Container + SafeArea + Row + Spacer with the
bar, which is the split it was already faking. A wide window gets the
full design instead: closed captions, reactions, recording and leaving
in a centred row, settings and the layout toggle leading, stats and the
participant and chat panels trailing.

The layout toggle drops out of the app bar past the large breakpoint,
where it lives in the bar's leading slot instead. Picture-in-picture is
the one control in the design with nothing to wire to: the SDK only
enters it when the app backgrounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bar was taking core's chrome colour, `backgroundElevation1`, which is
what StreamBottomAppBar and StreamAppBar use. The call screen does not:
CallAppBar and the call content's scaffold are both `backgroundApp`.
Lifting only the bottom bar left the two ends of one call screen a shade
apart in dark mode. Light mode paints all three elevations white, which
is why the design frames and the light goldens agreed either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Another thing carried over from StreamBottomAppBar that the call screen
does not want. Core's bars treat a `borderSubtle` line along the top
edge as part of their identity because they separate one page of content
from another; a call has nothing to separate the controls from, and the
design frames draw no line — their top row of pixels is the fill colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control bar's centre row already ends in LeaveCallOption there, so
the screen offered two ways to hang up a few hundred pixels apart. Same
reasoning as the layout toggle, and now the same flag drives both.

Below the breakpoint it stays: the phone bar has no leave button — the
design does not give it one — so the app bar is the only way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android bar carried six controls where the design draws five, and
its microphone and camera were split buttons with a caret each. On a
phone that is a device picker nobody has room to aim at: one microphone,
two cameras. Screen sharing and the pickers stay reachable from the more
menu.

Medium takes what small used to be, rather than falling back to it. A
tablet has the width for screen sharing and a caret on each device, and
changing microphone mid-call without opening a menu is worth two extra
controls at that size.

The phone's toggles are plain CallControlButtons rather than the SDK's
ToggleMicrophoneOption / ToggleCameraOption: those drop the Result that
setMicrophoneEnabled returns, which is the silent failure this app
already works around. An unavailable device is badged and disabled, not
painted destructive — being absent is not a user choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ToggleMicrophoneOption` and `ToggleCameraOption` no longer drop the
`Result` their setter returns. A refusal is logged, and reported to a new
`onError`. Dropping it made a refused press invisible: a control's state
comes from the call's own participant state, which does not change when
the call says no, so a viewer without `sendAudio` got no movement, no
message and no log.

The split buttons and the participants control now take the call in their
default constructor, matching `ToggleScreenShareOption` and the rest of
this package. Each does the wiring every call site was repeating: reading
the call's device state, toggling it, owning a device controller, and
disabling itself when the platform names no such device. Pass `devices`
to share one controller with a screen's other pickers.

The old shapes are named constructors — `withDevices` and
`forParticipants` — for callers that own the state and the action. That
is what a lobby needs, where nobody has joined and there is no call state
to read, and it is what the `StreamLobby*` wrappers use.

None of this has shipped, so there is nothing to deprecate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call screen's device helpers were the wiring that has moved into the
SDK: folding the setter's Result, computing whether the platform reported
a device, and mapping participants to UserInfo. What is left is the part
that is genuinely this app's — a snackbar on refusal, `onTap` opening its
own participants screen, and the shared device controller the settings
menu also reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ToggleMicrophoneOption` is `StreamMicrophoneButton`, and the other nine
controls follow: camera, screen share, recording, closed captions,
layout, speakerphone, flip camera, add reaction and leave call. The
family now matches the split buttons, the CallControlButton /
CallFeatureButton primitives, and the design system's own components.
"Toggle" went with the prefix — it described the press rather than the
control, and half the family never toggled anything.

Every old name survives as a deprecated typedef, and a typedef to a
class carries its constructors, so an unmigrated call site keeps
compiling whatever arguments it passes. fix_data.yaml renames all ten,
verified with `dart fix --apply` against a file using the old names.

Alongside it, the microphone and camera buttons take an optional
`devices` and, given one, badge themselves and stop responding while the
platform reports no such device — neutral rather than the negative tone
a deliberate mute gets, since an absent device is not a user choice.
Only the split buttons could say this before, so a narrow control bar
built from the plain toggles lost it. Optional because a plain toggle
needs no controller, and enumerating devices to draw one is a cost a
screen should opt into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the SDK rename, and hands the phone bar's plain microphone and
camera buttons this screen's device controller so a device the platform
does not report is badged and inert there too — it was, before that bar
swapped its split buttons for plain toggles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CallParticipantState.isAudioEnabled` is `!(audioTrack?.muted ?? true)`,
so a track the SFU has not named yet reads exactly like one the user
muted. Every microphone and camera control drew red with a slash for the
second between joining and the first track arriving.

The two are distinguishable: `_toggleTrackType` keeps the track entry and
flags `muted` rather than dropping it, so an absent entry means nothing
has reported the track. New `CallParticipantState.trackEnabled` returns
null for that case, and while it is null a control draws the intent the
call was joined with — `TrackOption.wantsOn`, where a provided track
counts as on, since the lobby only hands one over for a device it opened.
A reported track always wins, so muting a call joined unmuted sticks.

The intent is read only when the track is unreported, so a control whose
track has arrived never reaches for the call's options.

The existing tests stubbed `isAudioEnabled` directly; they stub
`publishedTracks` now, which is what production derives it from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bar is `kStreamToolbarHeight` (72) tall with the same edge padding
at every breakpoint. `CallControlBarStyle`'s `compactHeight` /
`expandedHeight` and `compactPadding` / `expandedPadding` collapse into
`height` and `padding`.

Changing geometry across a breakpoint made the bar jump as a desktop
window was dragged over 1280, which read as a glitch rather than as a
layout. Only which controls are drawn varies by size now, which is the
distinction that earns its keep.

The padding default is `spacing.sm` (12) rather than the design's 16: a
control is 40 visible inside a 48 tap target, which contributes the other
4 on every edge. That lands the visible inset on 16 horizontally and
vertically at once — the same pair of values StreamBottomAppBar uses at
the same height — and a test pins the rendered inset rather than only the
token behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CallFeatureTone.destructive` paints a feature red while it is on instead
of accent blue, and `StreamRecordingButton` uses it: a running recording
is capturing the call, which the design marks out from a feature that is
merely active.

Only the selected state changes. An unselected feature stays the same
secondary grey whatever its tone, matching the design system component
across its Captions, Screen Share, Picture in Picture and Record
variants — and a test pins that, so a row of features keeps reading as
one row until something is switched on.

The red needs no new colour: `StreamButtonStyle.destructive` resolves to
`accentError`, 0xFFD90D10, which is the pixel the design draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No in-call device menu has ever shown a selection, on any platform.
`StreamMediaDevicesController.forCall` reports
`supportsSystemDefault: false` — a call's setters take a device, so
there is no row for "let the platform pick" — and the selection stayed
null until something was picked, so `_options` gave every row
`selected: false`. The lobby escaped it because null has a row there.
The control bar's split buttons are the first in-call menus, which is
why it surfaced now.

Where null cannot be drawn, the selection getters resolve to the device
the platform reports as its own: the entry under the reserved `default`
id, which web lists as "Default - <name>" beside the real devices. There
it is a device like any other, so the row is selectable, and `_select`
now compares against the resolved value so picking it applies nothing —
the platform is already using it. A platform naming no such entry is
unchanged.

Nothing in the SDK records what the platform picked for itself:
`CallState.audioInputDevice` and a local track's `sourceDevice` are both
written only by an explicit `setAudioInputDevice`. Reading the browser's
own answer off `getSettings()` would cover the microphone but not the
speaker, which has no track to ask.

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