Skip to content

feat(ui): pick any participant layout from a menu - #1332

Merged
renefloor merged 9 commits into
v2from
claude/participant-layout-menu-f0e48f
Sep 11, 2026
Merged

feat(ui): pick any participant layout from a menu#1332
renefloor merged 9 commits into
v2from
claude/participant-layout-menu-f0e48f

Conversation

@renefloor

@renefloor renefloor commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

StreamLayoutButton flipped between two layouts and drew one of two icons. It now draws the layout in effect and offers the rest through a StreamAdaptiveMenuAnchor: a context menu on a desktop platform, a bottom sheet on Android and iOS. Given two layouts it toggles instead of opening a menu, which is what its default pair does.

Design: menu, sheet.

The seven layouts

Mode Label Renders
auto Default Speaker full-frame in a one-on-one call, a grid otherwise. The self-view floats over the speaker, and over the grid on mobile while at most two other people are in the call.
grid Grid Even grid, local participant as an ordinary tile.
speakerTop / speakerBottom / speakerLeft / speakerRight Speaker (…) Spotlight on that edge, with the participants bar along the opposite one.
speakerOneToOne Speaker 1:1 Spotlight alone, local participant floating over it.

A layout is named after where the speaker sits, not the bar — that is what the design's icons draw, so speakerTop sets ParticipantsBarAlignment.bottom. The four map onto the alignment CallParticipantsSpotlightView already had and RegularCallParticipantsContent never set. Screen share is untouched: StreamCallParticipants.build returns ScreenShareCallParticipantsContent before layoutMode is read, and that widget picks its own alignment from the window aspect ratio.

Notes for review

  • auto is the new default for StreamCallContent, StreamCallParticipants and RegularCallParticipantsContent. grid now gives the local participant a tile instead of floating them. The livestream widgets still default to grid, and are otherwise untouched — the new layouts there are for a later PR with designs of their own.
  • spotlightspeakerTop, pictureInPicturespeakerOneToOne, both deprecated with fix_data.yaml transforms. spotlight took the spotlight view's default bottom bar, which is speakerTop under the naming above. dart fix --apply migrates call sites; verified end to end on a probe file.
  • enableLocalVideo is enableFloatingSelfView now, and localVideoParticipantBuilder is floatingSelfViewBuilder. The flag decides whether the self-view floats over the layout or the local participant takes a tile like anybody else — the old name read as "show my video at all", which the grid and the four bar layouts ignore because those give them a tile either way. dart fix --apply renames both.
  • Only auto and speakerOneToOne read it, the two layouts that leave the local participant out of the arrangement. Under speakerOneToOne it defaults to on; under auto in a group call, to on for mobile with at most two other people and off beyond that. That threshold matches the other SDKs: Android gates on callParticipants.size in 2..3, React Native on remoteParticipants.length < 3, Swift on participants.count <= 3.
  • speakerOneToOne floats the self-view on desktop too, where the flag otherwise defaults to off. The inset is that layout — without it the local participant, who gets no tile and no bar either, disappears from the call. An explicit false still wins.
  • A speaker layout needs somebody to spotlight. participants.first threw StateError on an empty list; five layouts reach that branch now and each is one tap away, so it falls through to the grid instead.
  • A layout with no bar always spotlights a remote. With the self-view off, the speaker sort could put the local participant first and spotlight them, and since that layout draws no bar, every remote fell off the screen.
  • Changing the layout re-sorts. StreamCallParticipants derives its sort from layoutMode but recalculated only when the participants or the call changed, so picking a speaker layout swapped the preset without reordering and spotlighted whoever the previous preset had first. On a quiet call nothing arrived to correct it. Pre-existing, but a seven-entry menu makes it reachable.
  • StreamContextMenuAnchor.defaultActionStyle is new and is the interesting bit. The design fills the selected row, which the sheet did via StreamListTile(selected:) but the anchored menu could not — StreamContextMenuAction has no selected state, and the radio indicator that would otherwise mark it is suppressed by a per-row leading icon. Nesting a second StreamContextMenuActionTheme does not work: .of uses dependOnInheritedWidgetOfExactType, so it shadows rather than layers, and the selected row would drop to core's 40px/242px/20px defaults while its siblings keep the design's 32px/200px/16px. Hoisting the row style makes it re-mergeable. A test pins the metrics alongside the fill.
  • menuDirection, not direction, matching the device split buttons, and defaulting to down alongside them so every call control with a menu defaults the same way.
  • The sample offers every layout, minus speakerLeft/speakerRight below 768px, where a column of participants beside the speaker leaves the speaker too narrow.

Testing

flutter analyze clean for stream_video_flutter and the sample. 399 tests pass locally; the 38 golden tests need CI to generate their baselines.

Four test files cover the menu, the toggle, menuDirection, what each layout renders, the self-view rules including the mobile threshold and the desktop default, the re-sort on a layout change, and the enum's own derivations — canonical, selectable, barAlignment, isSpeakerLayout and sorting. The two fixes above each have a test that fails without the change. The menu is asserted rather than snapshotted — the CI golden capture drops anything painted into an Overlay.

Checked by eye on macOS and iOS against the Figma: seven rows on the menu, five on the sheet at phone width, per-row icons, active row filled.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 9, 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: ee0d9a14-e7ac-4bb6-9be0-411803356a6e

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

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.37500% with 17 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v2@11c1f90). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...zations/stream_video_flutter_localizations_nl.dart 0.00% 9 Missing ⚠️
...l_participants/layout/participant_layout_mode.dart 90.76% 6 Missing ⚠️
...r/lib/src/call_participants/call_participants.dart 85.71% 1 Missing ⚠️
...er/lib/src/call_participants/livestream_hosts.dart 0.00% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff          @@
##             v2    #1332   +/-   ##
=====================================
  Coverage      ?   29.08%           
=====================================
  Files         ?      374           
  Lines         ?    28922           
  Branches      ?        0           
=====================================
  Hits          ?     8413           
  Misses        ?    20509           
  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 claude/participant-layout-menu-f0e48f branch from dab5a5c to 009904c Compare September 9, 2026 12:05
Base automatically changed from feat/call-controls to v2 September 10, 2026 12:06
@renefloor
renefloor force-pushed the claude/participant-layout-menu-f0e48f branch from 009904c to f4a420a Compare September 10, 2026 12:16
@renefloor
renefloor marked this pull request as ready for review September 10, 2026 14:55
@renefloor
renefloor requested a review from a team as a code owner September 10, 2026 14:55
renefloor and others added 8 commits September 10, 2026 16:56
StreamLayoutButton flipped between two layouts and drew one of two icons.
It now draws the layout in effect and offers the rest through a
StreamAdaptiveMenuAnchor — a context menu on desktop and web, a bottom
sheet on Android and iOS. Given two layouts it toggles instead, which is
what its default pair does.

ParticipantLayoutMode grows to auto, grid, speakerTop, speakerBottom,
speakerLeft, speakerRight and speakerOneToOne, mapping the four bar
layouts onto the ParticipantsBarAlignment the spotlight view already had.
spotlight and pictureInPicture stay as deprecated aliases with dart fix
transforms.

The anchored menu now fills the row of the selected option, as the sheet
already did. That needs StreamContextMenuAnchor.defaultActionStyle:
StreamContextMenuActionTheme.of reads only the nearest theme, so varying
one row has to re-merge the design's metrics rather than layer over them.

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

Builds the exclusion as a list and filters with whereNot, rather than a
negated compound condition inside a comprehension. Both lists are const,
so a rebuild allocates neither.

Also formats stream_layout_button_test.dart, which `melos run format`
skipped while it was still untracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default and the layouts it can apply to were two booleans apart: the
platform default was computed for every layout and then discarded for the
five that give the local participant a tile. One switch now says, per
layout, whether the self-view floats and what it defaults to.

No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four bar layouts mapped their name onto the participants bar's edge:
speakerTop set ParticipantsBarAlignment.top, which renders
Column([bar, spotlight]) and puts the speaker at the bottom. The design
names them after the speaker — speaker-top-fill.svg draws the large tile
along the top — so the menu offered a row whose icon and label said the
opposite of what it rendered. All four now put the bar on the edge
opposite the speaker.

That moves the spotlight alias too: it used to take the spotlight view's
default bottom bar, which is speakerTop under the corrected naming, so
canonical, fix_data.yaml and the livestream single-host layout follow.

Also guard the speaker branch against an empty participant list, where
participants.first threw StateError. Five layouts reach that branch now
and every one of them is a tap away in the new menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device split buttons already expose this as menuDirection; direction
read as the button's own direction rather than its menu's. It defaults to
down alongside them now, so every call control with a menu defaults the
same way. Both are new in this unreleased major, so no transform is
needed.

Also pin the mapping: menuDirection.up offsets the panel by -8 and down
by 8, which nothing asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
enableLocalVideo is enableFloatingSelfView, and its builder is
floatingSelfViewBuilder. The flag decides whether the local participant's
self-view floats over the layout or they take a tile like anybody else, so
the old name read as "show my video at all" — which the grid and the four
bar layouts ignore, because those give them a tile either way.

dart fix --apply renames both on StreamCallParticipants. The pair is also
on RegularCallParticipantsContent, which is not exported and needs no
transform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A speaker layout with no participants bar shows nobody but the spotlight,
so it now goes to a remote whenever there is one. Under speakerOneToOne
with the self-view off, the speaker sort could put the local participant
first and spotlight them, leaving every remote off the screen. A bar
layout still spotlights whoever the sort put first, the local participant
included, since everybody else is in the bar.

StreamCallParticipants derives its sort from layoutMode, but recalculated
only when the participants or the call changed, so picking a speaker
layout swapped the preset without reordering the list and spotlighted
whoever the previous preset had first. On a quiet call no emission
arrived to correct it.

Both are covered by tests that fail without the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Under auto, the self-view floats on mobile only while at most two other
people are in the call. Beyond that the grid is crowded enough on a phone
that the inset covers a tile, so the local participant takes one of their
own. Desktop is unchanged, and an explicit enableFloatingSelfView still
wins either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@renefloor
renefloor force-pushed the claude/participant-layout-menu-f0e48f branch from 993cc16 to 64ac245 Compare September 10, 2026 14:58
Comment thread packages/stream_video_flutter/lib/src/call_participants/call_participants.dart Outdated
The re-sort was keyed on the resolved sort, which is a function: an inline
sort: closure is a new object on every build, so a call site passing one
re-sorted on every rebuild. layoutMode.sorting is one of two cached
presets, so comparing those says what was meant — the layout changed the
preset — and never compares a caller's closure.

Also says in the changelog that enableFloatingSelfView is ignored, rather
than merely unread, on the layouts that give the local participant a tile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@renefloor
renefloor merged commit 22e873a into v2 Sep 11, 2026
10 checks passed
@renefloor
renefloor deleted the claude/participant-layout-menu-f0e48f branch September 11, 2026 09:17
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.

2 participants