Skip to content

feat(ui): call controls - #1324

Open
renefloor wants to merge 25 commits into
v2from
feat/call-controls
Open

feat(ui): call controls#1324
renefloor wants to merge 25 commits into
v2from
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

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: Advanced

Run ID: 1165b00d-208c-490e-b6a4-e76ff57ef292

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

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.85897% with 41 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v2@e82184d). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...er/lib/src/call_controls/device_split_buttons.dart 90.24% 8 Missing ⚠️
...c/call_controls/controls/stream_layout_button.dart 0.00% 7 Missing ⚠️
...b/src/theme/components/call_control_bar_theme.dart 53.33% 7 Missing ⚠️
packages/stream_video/lib/src/call/call.dart 71.42% 4 Missing ⚠️
...all_controls/controls/default_control_options.dart 0.00% 4 Missing ⚠️
..._controls/controls/stream_add_reaction_button.dart 0.00% 2 Missing ⚠️
..._controls/controls/stream_speakerphone_button.dart 0.00% 2 Missing ⚠️
...ntrols/controls/stream_closed_captions_button.dart 0.00% 1 Missing ⚠️
...l_controls/controls/stream_flip_camera_button.dart 0.00% 1 Missing ⚠️
...ll_controls/controls/stream_leave_call_button.dart 0.00% 1 Missing ⚠️
... and 4 more
Additional details and impacted files
@@          Coverage Diff          @@
##             v2    #1324   +/-   ##
=====================================
  Coverage      ?   27.88%           
=====================================
  Files         ?      375           
  Lines         ?    28805           
  Branches      ?        0           
=====================================
  Hits          ?     8032           
  Misses        ?    20773           
  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
renefloor force-pushed the feat/call-controls branch 2 times, most recently from 2aa6f20 to e69789a Compare September 5, 2026 12:00
Comment thread dogfooding/lib/screens/call_screen.dart Outdated
Comment thread packages/stream_video_flutter/lib/src/call_controls/call_control_bar.dart Outdated
@renefloor
renefloor marked this pull request as ready for review September 8, 2026 14:02
@renefloor
renefloor requested a review from a team as a code owner September 8, 2026 14:02
Base automatically changed from feat/lobby-view to v2 September 9, 2026 08:56
renefloor and others added 7 commits September 9, 2026 10:56
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>
renefloor and others added 18 commits September 9, 2026 10:56
`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>
A camera or microphone the call refuses at join left the intent set to on.
The setters only downgrade connectOptions on success, so a control reading
the intent while no track had been reported drew the device as live for the
rest of the call, and could not toggle it — there was no track to mute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Export device_control.dart; the changelog already announced trackEnabled
  and wantsOn, which no consumer could reach.
- StreamLayoutButton takes `layout` and keeps no state. Every caller already
  owned the mode, and the button ignored later changes to it.
- CallControlBar takes `small` by name, with super.key last.
- heightOf includes the bottom inset a primary bar clears, so a caller
  insetting content out from under a floating bar no longer falls short.
- Drop resolveSurfaceStyle, which had no callers, and share the one
  style-resolution chain the three readers had copied.
- onError takes a StreamDeviceErrorCallback: a typed VideoError plus the
  action that failed, so a caller names the action instead of guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The closure ran to seventy lines with three inline layouts. Two of its
comments were also wrong: the settings menu keeps its own device notifier
rather than this screen's controller, and the tablet layout adds one
control, not two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`reportsNo` is true whether the platform has no such device or could not
list one at all, so a control badging itself off it could not say whether a
retry was worth offering. `enumerationFailed` reads the reason the
controller already records.

Also corrects the `devices` docs: the split buttons always enumerate,
building a controller for the call when none is passed, and reading the
error or retrying needs a controller the screen holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one of these fails against a deliberate fault and passed before:

- StreamCameraButton had no test of its own. Pointing it at the
  microphone's device list and join intent left the suite green.
- StreamMicrophoneSplitButton's call-driven path was never instantiated;
  the camera tests give it nothing, the two being separate implementations.
- stopTrackOnMute reached no test at all.
- primary/SafeArea reached none either, and the golden cannot help: its
  MediaQuery replaces the whole data object, zeroing the inset.
- The bar's layout choice was tested only through layoutFor, so
  hard-coding a screen size in build() passed every widget test.
- 'builds and disposes a controller of its own' asserted takeException is
  null, which a leak also satisfies. It now watches the device
  subscription, which a leak keeps alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Three docs still promised a hairline along the bar's top edge, which
  2e3f253 removed; the bar draws no border at all.
- The devices doc claimed the split button opts into enumeration the same
  way. It always enumerates, building a controller when passed none.
- The onError doc justified an untyped error by an analogy to
  enumerationError, which is a StreamDeviceError, and by VideoError being
  unnameable outside stream_video, which exports it.
- withDevices does no muting; the caller's onPressed does.
- selectedAudioInput led with the null case rather than the behaviour.
- Eight controls still opened with "a call control option".
- The speakerphone button logged under SV:SpeakerphoneOption.
- default_control_options.dart suppressed a deprecation it no longer has.
- Dropped the refactor narration the house style keeps out of doc
  comments, and documented the two undocumented screen-share fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new entries ran to several paragraphs and a code sample each, and two
carried claims the code does not make: a hairline along the bar's top edge,
and a height that varies with the window when it is one themed value at
every breakpoint. Entries inherited from feat/lobby-view are left as they
are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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