From 1c43cafe32841fce1b6cfac1f50ea32e49313ee8 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 15:12:56 +0200 Subject: [PATCH 01/10] fix(ui): show no tile chrome in the Android picture-in-picture window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PiP overlay draws a StreamParticipantTile, which at the window's size carries its full chrome: the name pill, the connection quality indicator and — for an app registering a `participantTile` builder that adds actions, as the dogfooding app does — an overflow button nothing in PiP can tap. Suppressed through the style, so the app-wide builder does not put the menu back, the same way the lobby preview does it. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 1 + .../android_pip_overlay.dart | 10 ++ .../android_pip_overlay_test.dart | 92 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9c70a21c9..9bb7fbd6f 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -156,6 +156,7 @@ - Fixed the participant grid rearranging itself when a participant nobody can see starts speaking. They take the place of the tile with the least claim to one — the last one on screen — instead of the first, which used to move every tile below it down one. - Fixed a participant tile on screen being recorded as not visible, which kept it out of the running for a speaker's tile and could get its track unsubscribed. A renderer showing a participant now says so again when the call state disagrees, and the floating self-view no longer shares its visibility bookkeeping with the same participant's tile in the grid. +- The Android picture-in-picture window shows the video alone: no name pill, connection quality indicator or overflow button. - The floating self-view draws no name pill, whatever an app-wide participant tile theme asks for. `StreamFloatingParticipantTileStyle.tileStyle` still can. - A participant tile keeps the name in its label at every size it draws the label at, truncating with an ellipsis. - The participant label stops growing at 268px, set by `StreamParticipantLabelStyle.maxWidth`. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 121806071..847d52b07 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -110,6 +110,16 @@ class _AndroidPipOverlayState extends State rendererScopePrefix: 'pipVideo', call: widget.call, participant: pipParticipant, + // The PiP window is a glance at the call, and nothing drawn in it can + // be tapped: Android routes taps to the window itself, not to the + // Flutter view. Suppressed through the style rather than the props so + // an app-wide `participantTile` builder that adds an overflow menu to + // every tile does not put one back here. + style: const StreamParticipantTileStyle( + showParticipantLabel: false, + showConnectionQualityIndicator: false, + showMoreButton: false, + ), ); } } diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart new file mode 100644 index 000000000..f068214fb --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../../../test_utils/test_wrapper.dart'; +import '../../../mocks.dart'; + +void main() { + group('AndroidPipOverlay', () { + late MockCall call; + late MockCallState callState; + late MockCallParticipantState participant; + + setUp(() { + call = MockCall(); + callState = MockCallState(); + participant = MockCallParticipantState(); + + when(() => participant.userId).thenReturn('rene'); + when(() => participant.uniqueParticipantKey).thenReturn('rene-session'); + when(() => participant.name).thenReturn('Rene Floor'); + when(() => participant.isLocal).thenReturn(true); + when(() => participant.isPinned).thenReturn(false); + when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isAudioEnabled).thenReturn(true); + when(() => participant.isVideoEnabled).thenReturn(true); + when(() => participant.isScreenShareEnabled).thenReturn(false); + when(() => participant.screenShareTrack).thenReturn(null); + when(() => participant.reaction).thenReturn(null); + when( + () => participant.connectionQuality, + ).thenReturn(SfuConnectionQuality.excellent); + when(() => participant.viewportVisibility).thenReturn( + ViewportVisibility.visible, + ); + when(() => callState.callParticipants).thenReturn([participant]); + + final emitter = MutableStateEmitter(callState, sync: true); + when(() => call.state).thenAnswer((_) => emitter); + when( + () => call.partialState>(any()), + ).thenAnswer((invocation) { + final CallStateSelector> selector = + invocation.positionalArguments[0]; + return Stream.value(selector(callState)); + }); + }); + + // The PiP window shows the video and nothing else. An app that registers a + // `participantTile` builder adding an overflow menu to every tile — which + // the dogfooding app does — must not get one here either. + testWidgets('draws the participant without any tile chrome', ( + tester, + ) async { + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + participantTile: (context, props) => DefaultStreamParticipantTile( + props: props.copyWith( + actionsBuilder: (context, participant) => [ + StreamParticipantTileAction( + icon: context.streamIcons.pin, + label: 'Pin', + onPressed: () {}, + ), + ], + ), + ), + // The real renderer needs a call publishing tracks. + participantVideo: (context, props) => const Text('renderer'), + ), + ), + child: TestWrapper( + child: SizedBox( + width: 200, + height: 300, + child: AndroidPipOverlay(call: call), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('renderer'), findsOneWidget); + expect(find.byType(StreamParticipantLabel), findsNothing); + expect(find.byType(StreamConnectionQualityIndicator), findsNothing); + expect(find.byIcon(const StreamIcons().moreHorizontal), findsNothing); + }); + }); +} From ce9b69781124beafb18d7d02a9e735956ac33e9e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 15:25:29 +0200 Subject: [PATCH 02/10] feat(ui): theme the participant tile in the picture-in-picture window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StreamPictureInPictureThemeData` on `StreamVideoTheme`, carrying a `tileStyle` merged over the window's own choices — so an app can restyle the tile the Android PiP window draws, and put back the name pill, connection quality indicator or overflow button it leaves out. The window is inserted into the nearest Overlay, which sits above the route showing the call, so a theme wrapped around the call screen is not an ancestor of it. Documented on the theme: set it on `StreamVideoTheme`, or above the Navigator. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 1 + .../android_pip_overlay.dart | 6 +- .../lib/src/theme/components/components.dart | 1 + .../components/picture_in_picture_theme.dart | 110 ++++++++++++ .../picture_in_picture_theme.g.theme.dart | 156 ++++++++++++++++++ .../lib/src/theme/stream_video_theme.dart | 20 +++ .../android_pip_overlay_test.dart | 48 +++++- 7 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart create mode 100644 packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.g.theme.dart diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9bb7fbd6f..ccbfb1608 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,6 +2,7 @@ ### ✅ Added +- Added `StreamPictureInPictureThemeData` on `StreamVideoTheme`, whose `StreamPictureInPictureStyle.tileStyle` restyles the participant tile the Android picture-in-picture window draws — including putting back the chrome it leaves out. - `StreamLayoutButton` draws the participant layout in effect and offers the rest through a `StreamAdaptiveMenuAnchor`. - `StreamLayoutButton.defaultLayouts` is `auto` and `speakerBottom`, so the button toggles unless it is given more. - Added layout strings to the localizations, in English and Dutch: `layoutMenuTitle`, `layoutSelectTooltip`, `layoutDefault`, `layoutGrid`, `layoutSpeakerTop`, `layoutSpeakerBottom`, `layoutSpeakerLeft`, `layoutSpeakerRight` and `layoutSpeakerOneToOne`. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 847d52b07..35f85b0b4 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -114,12 +114,14 @@ class _AndroidPipOverlayState extends State // be tapped: Android routes taps to the window itself, not to the // Flutter view. Suppressed through the style rather than the props so // an app-wide `participantTile` builder that adds an overflow menu to - // every tile does not put one back here. + // every tile does not put one back here. The tile merges this over + // the ambient participant tile theme, and the picture-in-picture + // theme's tileStyle over it, so that is what puts any of it back. style: const StreamParticipantTileStyle( showParticipantLabel: false, showConnectionQualityIndicator: false, showMoreButton: false, - ), + ).merge(StreamPictureInPictureTheme.of(context).style?.tileStyle), ); } } diff --git a/packages/stream_video_flutter/lib/src/theme/components/components.dart b/packages/stream_video_flutter/lib/src/theme/components/components.dart index 2f1b1f398..8aaa244f5 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/components.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/components.dart @@ -5,3 +5,4 @@ export 'floating_participant_tile_theme.dart'; export 'lobby_view_theme.dart'; export 'participant_label_theme.dart'; export 'participant_tile_theme.dart'; +export 'picture_in_picture_theme.dart'; diff --git a/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart new file mode 100644 index 000000000..0cbe3cb22 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart @@ -0,0 +1,110 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'picture_in_picture_theme.g.theme.dart'; + +/// Applies a picture-in-picture theme to the descendant +/// picture-in-picture window. +/// +/// The window is inserted into the nearest [Overlay], which in an app with a +/// [Navigator] sits above the route showing the call. A theme wrapped around +/// the call screen is therefore not an ancestor of it: set +/// [StreamVideoTheme.pictureInPictureTheme], or wrap this above the +/// [Navigator], to reach the window. +/// +/// See also: +/// +/// * [StreamPictureInPictureThemeData], which describes the theme. +/// * [StreamPictureInPictureStyle], the visual style it carries. +class StreamPictureInPictureTheme extends InheritedTheme { + /// Creates a picture-in-picture theme. + const StreamPictureInPictureTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The picture-in-picture theme data for descendant widgets. + final StreamPictureInPictureThemeData data; + + /// Returns the [StreamPictureInPictureThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamPictureInPictureTheme] ancestor take + /// precedence over the global values from + /// [StreamVideoTheme.pictureInPictureTheme]. + static StreamPictureInPictureThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).pictureInPictureTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamPictureInPictureTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamPictureInPictureTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing the picture-in-picture window. +/// +/// See also: +/// +/// * [StreamPictureInPictureStyle], the style embedded here. +/// * [StreamPictureInPictureTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamPictureInPictureThemeData with _$StreamPictureInPictureThemeData { + /// Creates picture-in-picture theme data. + const StreamPictureInPictureThemeData({this.style}); + + /// Visual styling for the picture-in-picture window. + final StreamPictureInPictureStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamPictureInPictureThemeData? lerp( + StreamPictureInPictureThemeData? a, + StreamPictureInPictureThemeData? b, + double t, + ) => _$StreamPictureInPictureThemeData.lerp(a, b, t); +} + +/// Visual styling properties for the picture-in-picture window. +/// +/// Applies to the window Android draws while the app is in +/// picture-in-picture mode. The iOS window is rendered natively and is +/// configured through `IOSPictureInPictureConfiguration` instead. +@themeGen +@immutable +class StreamPictureInPictureStyle with _$StreamPictureInPictureStyle { + /// Creates a picture-in-picture style with optional property overrides. + const StreamPictureInPictureStyle({this.tileStyle}); + + /// Overrides applied to the participant tile the window renders. + /// + /// Merged over the ambient [StreamParticipantTileTheme] style, so an app-wide + /// tile customization still reaches the window, and over the window's own + /// choices, so this is what puts any of them back. The window draws no name + /// pill, no connection quality indicator and no overflow button: it is a + /// glance at the call, and Android delivers taps to the window rather than + /// to what is drawn in it. + /// + /// Nothing here reaches a window built by + /// `AndroidPictureInPictureConfiguration.callPictureInPictureWidgetBuilder` — + /// that replaces what this styles. + final StreamParticipantTileStyle? tileStyle; + + /// Linearly interpolate between two styles. + static StreamPictureInPictureStyle? lerp( + StreamPictureInPictureStyle? a, + StreamPictureInPictureStyle? b, + double t, + ) => _$StreamPictureInPictureStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.g.theme.dart new file mode 100644 index 000000000..6db1a3b32 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.g.theme.dart @@ -0,0 +1,156 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'picture_in_picture_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPictureInPictureThemeData { + bool get canMerge => true; + + static StreamPictureInPictureThemeData? lerp( + StreamPictureInPictureThemeData? a, + StreamPictureInPictureThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle.lerp(a.style, b.style, t), + ); + } + + StreamPictureInPictureThemeData copyWith({ + StreamPictureInPictureStyle? style, + }) { + final _this = (this as StreamPictureInPictureThemeData); + + return StreamPictureInPictureThemeData(style: style ?? _this.style); + } + + StreamPictureInPictureThemeData merge( + StreamPictureInPictureThemeData? other, + ) { + final _this = (this as StreamPictureInPictureThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPictureInPictureThemeData); + final _other = (other as StreamPictureInPictureThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamPictureInPictureThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamPictureInPictureStyle { + bool get canMerge => true; + + static StreamPictureInPictureStyle? lerp( + StreamPictureInPictureStyle? a, + StreamPictureInPictureStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle.lerp(a.tileStyle, b.tileStyle, t), + ); + } + + StreamPictureInPictureStyle copyWith({ + StreamParticipantTileStyle? tileStyle, + }) { + final _this = (this as StreamPictureInPictureStyle); + + return StreamPictureInPictureStyle(tileStyle: tileStyle ?? _this.tileStyle); + } + + StreamPictureInPictureStyle merge(StreamPictureInPictureStyle? other) { + final _this = (this as StreamPictureInPictureStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + tileStyle: _this.tileStyle?.merge(other.tileStyle) ?? other.tileStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPictureInPictureStyle); + final _other = (other as StreamPictureInPictureStyle); + + return _other.tileStyle == _this.tileStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPictureInPictureStyle); + + return Object.hash(runtimeType, _this.tileStyle); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart index d14eab5d6..c52975a7c 100644 --- a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart @@ -40,6 +40,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantTileThemeData? participantTileTheme, StreamFloatingParticipantTileThemeData? floatingParticipantTileTheme, StreamParticipantLabelThemeData? participantLabelTheme, + StreamPictureInPictureThemeData? pictureInPictureTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, StreamLivestreamThemeData? livestreamTheme, @@ -81,6 +82,7 @@ class StreamVideoTheme extends ThemeExtension { floatingParticipantTileTheme: floatingParticipantTileTheme, participantLabelTheme: participantLabelTheme ?? legacy?.toParticipantLabelThemeData(), + pictureInPictureTheme: pictureInPictureTheme, connectionQualityIndicatorTheme: connectionQualityIndicatorTheme ?? legacy?.toConnectionQualityIndicatorThemeData(), @@ -127,6 +129,7 @@ class StreamVideoTheme extends ThemeExtension { this.floatingParticipantTileTheme = const StreamFloatingParticipantTileThemeData(), this.participantLabelTheme = const StreamParticipantLabelThemeData(), + this.pictureInPictureTheme = const StreamPictureInPictureThemeData(), this.connectionQualityIndicatorTheme = const StreamConnectionQualityIndicatorThemeData(), this.callParticipantsGridTheme = @@ -407,6 +410,9 @@ class StreamVideoTheme extends ThemeExtension { /// Theme for the participant tile's name pill. final StreamParticipantLabelThemeData participantLabelTheme; + /// Theme for the picture-in-picture window. + final StreamPictureInPictureThemeData pictureInPictureTheme; + /// Theme for the connection quality indicator. final StreamConnectionQualityIndicatorThemeData connectionQualityIndicatorTheme; @@ -452,6 +458,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantTileThemeData? participantTileTheme, StreamFloatingParticipantTileThemeData? floatingParticipantTileTheme, StreamParticipantLabelThemeData? participantLabelTheme, + StreamPictureInPictureThemeData? pictureInPictureTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, StreamLivestreamThemeData? livestreamTheme, @@ -476,6 +483,9 @@ class StreamVideoTheme extends ThemeExtension { participantLabelTheme: this.participantLabelTheme.merge( participantLabelTheme, ), + pictureInPictureTheme: this.pictureInPictureTheme.merge( + pictureInPictureTheme, + ), connectionQualityIndicatorTheme: this.connectionQualityIndicatorTheme.merge( connectionQualityIndicatorTheme, ), @@ -511,6 +521,9 @@ class StreamVideoTheme extends ThemeExtension { participantLabelTheme: participantLabelTheme.merge( other.participantLabelTheme, ), + pictureInPictureTheme: pictureInPictureTheme.merge( + other.pictureInPictureTheme, + ), connectionQualityIndicatorTheme: connectionQualityIndicatorTheme.merge( other.connectionQualityIndicatorTheme, ), @@ -577,6 +590,13 @@ class StreamVideoTheme extends ThemeExtension { t, ) ?? participantLabelTheme, + pictureInPictureTheme: + StreamPictureInPictureThemeData.lerp( + pictureInPictureTheme, + other.pictureInPictureTheme, + t, + ) ?? + pictureInPictureTheme, connectionQualityIndicatorTheme: StreamConnectionQualityIndicatorThemeData.lerp( connectionQualityIndicatorTheme, diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index f068214fb..59cd9c900 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -47,12 +47,13 @@ void main() { }); }); - // The PiP window shows the video and nothing else. An app that registers a - // `participantTile` builder adding an overflow menu to every tile — which - // the dogfooding app does — must not get one here either. - testWidgets('draws the participant without any tile chrome', ( - tester, - ) async { + // An app that registers a `participantTile` builder adding an overflow menu + // to every tile — which the dogfooding app does — must not get one in the + // window either. + Future pumpOverlay( + WidgetTester tester, { + StreamPictureInPictureThemeData? pictureInPictureTheme, + }) async { await tester.pumpWidget( StreamComponentFactory( builders: StreamComponentBuilders( @@ -76,17 +77,50 @@ void main() { child: SizedBox( width: 200, height: 300, - child: AndroidPipOverlay(call: call), + child: switch (pictureInPictureTheme) { + final theme? => StreamPictureInPictureTheme( + data: theme, + child: AndroidPipOverlay(call: call), + ), + null => AndroidPipOverlay(call: call), + }, ), ), ), ); await tester.pumpAndSettle(); + } + + testWidgets('draws the participant without any tile chrome', ( + tester, + ) async { + await pumpOverlay(tester); expect(find.text('renderer'), findsOneWidget); expect(find.byType(StreamParticipantLabel), findsNothing); expect(find.byType(StreamConnectionQualityIndicator), findsNothing); expect(find.byIcon(const StreamIcons().moreHorizontal), findsNothing); }); + + testWidgets('draws the chrome the picture-in-picture theme asks back', ( + tester, + ) async { + await pumpOverlay( + tester, + pictureInPictureTheme: const StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle( + showParticipantLabel: true, + showConnectionQualityIndicator: true, + showMoreButton: true, + ), + ), + ), + ); + + expect(find.byType(StreamParticipantLabel), findsOneWidget); + expect(find.byType(StreamConnectionQualityIndicator), findsOneWidget); + expect(find.byIcon(const StreamIcons().moreHorizontal), findsOneWidget); + }); }); } From d94b325e35c12ba8a0f3a42924658221c01bf82f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 15:29:36 +0200 Subject: [PATCH 03/10] chore(ui): trim the picture-in-picture comments Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 2 +- .../android_pip_overlay.dart | 7 ----- .../components/picture_in_picture_theme.dart | 31 +++++++------------ .../android_pip_overlay_test.dart | 5 ++- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index ccbfb1608..881d3746f 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,7 +2,7 @@ ### ✅ Added -- Added `StreamPictureInPictureThemeData` on `StreamVideoTheme`, whose `StreamPictureInPictureStyle.tileStyle` restyles the participant tile the Android picture-in-picture window draws — including putting back the chrome it leaves out. +- Added `StreamPictureInPictureThemeData` on `StreamVideoTheme`, whose `StreamPictureInPictureStyle.tileStyle` restyles the participant tile the Android picture-in-picture window draws. - `StreamLayoutButton` draws the participant layout in effect and offers the rest through a `StreamAdaptiveMenuAnchor`. - `StreamLayoutButton.defaultLayouts` is `auto` and `speakerBottom`, so the button toggles unless it is given more. - Added layout strings to the localizations, in English and Dutch: `layoutMenuTitle`, `layoutSelectTooltip`, `layoutDefault`, `layoutGrid`, `layoutSpeakerTop`, `layoutSpeakerBottom`, `layoutSpeakerLeft`, `layoutSpeakerRight` and `layoutSpeakerOneToOne`. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 35f85b0b4..081db6b4d 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -110,13 +110,6 @@ class _AndroidPipOverlayState extends State rendererScopePrefix: 'pipVideo', call: widget.call, participant: pipParticipant, - // The PiP window is a glance at the call, and nothing drawn in it can - // be tapped: Android routes taps to the window itself, not to the - // Flutter view. Suppressed through the style rather than the props so - // an app-wide `participantTile` builder that adds an overflow menu to - // every tile does not put one back here. The tile merges this over - // the ambient participant tile theme, and the picture-in-picture - // theme's tileStyle over it, so that is what puts any of it back. style: const StreamParticipantTileStyle( showParticipantLabel: false, showConnectionQualityIndicator: false, diff --git a/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart index 0cbe3cb22..f35388288 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart @@ -5,14 +5,12 @@ import '../../../stream_video_flutter.dart'; part 'picture_in_picture_theme.g.theme.dart'; -/// Applies a picture-in-picture theme to the descendant -/// picture-in-picture window. +/// Applies a picture-in-picture theme to the descendant picture-in-picture +/// window. /// -/// The window is inserted into the nearest [Overlay], which in an app with a -/// [Navigator] sits above the route showing the call. A theme wrapped around -/// the call screen is therefore not an ancestor of it: set -/// [StreamVideoTheme.pictureInPictureTheme], or wrap this above the -/// [Navigator], to reach the window. +/// The window is inserted into the nearest [Overlay], above the route showing +/// the call. Set [StreamVideoTheme.pictureInPictureTheme], or wrap this above +/// the [Navigator], to reach it. /// /// See also: /// @@ -78,9 +76,8 @@ class StreamPictureInPictureThemeData with _$StreamPictureInPictureThemeData { /// Visual styling properties for the picture-in-picture window. /// -/// Applies to the window Android draws while the app is in -/// picture-in-picture mode. The iOS window is rendered natively and is -/// configured through `IOSPictureInPictureConfiguration` instead. +/// Applies to the window Android draws. The iOS window is rendered natively +/// and is configured through `IOSPictureInPictureConfiguration`. @themeGen @immutable class StreamPictureInPictureStyle with _$StreamPictureInPictureStyle { @@ -89,16 +86,12 @@ class StreamPictureInPictureStyle with _$StreamPictureInPictureStyle { /// Overrides applied to the participant tile the window renders. /// - /// Merged over the ambient [StreamParticipantTileTheme] style, so an app-wide - /// tile customization still reaches the window, and over the window's own - /// choices, so this is what puts any of them back. The window draws no name - /// pill, no connection quality indicator and no overflow button: it is a - /// glance at the call, and Android delivers taps to the window rather than - /// to what is drawn in it. + /// Merged over the ambient [StreamParticipantTileTheme] style and over the + /// window's own choices: it draws no name pill, connection quality indicator + /// or overflow button, and this is what puts them back. /// - /// Nothing here reaches a window built by - /// `AndroidPictureInPictureConfiguration.callPictureInPictureWidgetBuilder` — - /// that replaces what this styles. + /// Has no effect on a window built by + /// `AndroidPictureInPictureConfiguration.callPictureInPictureWidgetBuilder`. final StreamParticipantTileStyle? tileStyle; /// Linearly interpolate between two styles. diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 59cd9c900..7afd11e47 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -47,9 +47,8 @@ void main() { }); }); - // An app that registers a `participantTile` builder adding an overflow menu - // to every tile — which the dogfooding app does — must not get one in the - // window either. + // The registered tile builder adds an overflow menu to every tile, the way + // the dogfooding app does. Future pumpOverlay( WidgetTester tester, { StreamPictureInPictureThemeData? pictureInPictureTheme, From a76f9d541e1e0fe4eaedec612c7449d93cad0bd6 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:19:34 +0200 Subject: [PATCH 04/10] fix(ui): keep the name and connection quality in the picture-in-picture window The window draws the name pill and the connection quality indicator again, without the camera-off icon or the sound indicator, and still without the overflow button. Adds `StreamParticipantLabelStyle.showVideoOffIcon` for the camera-off icon, which had no switch of its own. The pill also draws nothing at all now when it has neither a name nor an indicator to show, rather than an empty rounded rectangle over the video. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 4 +- .../call_participants/participant_label.dart | 11 ++- .../participant_label_defaults.dart | 7 +- .../android_pip_overlay.dart | 6 +- .../components/participant_label_theme.dart | 8 +++ .../participant_label_theme.g.theme.dart | 8 ++- .../participant_label_test.dart | 70 +++++++++++++++++++ .../android_pip_overlay_test.dart | 29 +++++--- 8 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 packages/stream_video_flutter/test/src/call_participants/participant_label_test.dart diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 881d3746f..117b4764f 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -3,6 +3,7 @@ ### ✅ Added - Added `StreamPictureInPictureThemeData` on `StreamVideoTheme`, whose `StreamPictureInPictureStyle.tileStyle` restyles the participant tile the Android picture-in-picture window draws. +- Added `StreamParticipantLabelStyle.showVideoOffIcon`, to leave the camera-off icon out of the name pill. - `StreamLayoutButton` draws the participant layout in effect and offers the rest through a `StreamAdaptiveMenuAnchor`. - `StreamLayoutButton.defaultLayouts` is `auto` and `speakerBottom`, so the button toggles unless it is given more. - Added layout strings to the localizations, in English and Dutch: `layoutMenuTitle`, `layoutSelectTooltip`, `layoutDefault`, `layoutGrid`, `layoutSpeakerTop`, `layoutSpeakerBottom`, `layoutSpeakerLeft`, `layoutSpeakerRight` and `layoutSpeakerOneToOne`. @@ -157,7 +158,8 @@ - Fixed the participant grid rearranging itself when a participant nobody can see starts speaking. They take the place of the tile with the least claim to one — the last one on screen — instead of the first, which used to move every tile below it down one. - Fixed a participant tile on screen being recorded as not visible, which kept it out of the running for a speaker's tile and could get its track unsubscribed. A renderer showing a participant now says so again when the call state disagrees, and the floating self-view no longer shares its visibility bookkeeping with the same participant's tile in the grid. -- The Android picture-in-picture window shows the video alone: no name pill, connection quality indicator or overflow button. +- The Android picture-in-picture window draws no overflow button, camera-off icon or sound indicator. It keeps the name pill and the connection quality indicator. +- The name pill draws nothing at all when it has neither a name nor an indicator, instead of an empty rounded rectangle over the video. - The floating self-view draws no name pill, whatever an app-wide participant tile theme asks for. `StreamFloatingParticipantTileStyle.tileStyle` still can. - A participant tile keeps the name in its label at every size it draws the label at, truncating with an ellipsis. - The participant label stops growing at 268px, set by `StreamParticipantLabelStyle.maxWidth`. diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart index 0bc3b6d59..0388ff241 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart @@ -178,7 +178,8 @@ class DefaultStreamParticipantLabel extends StatelessWidget { nameTextStyle.color ?? defaults.microphoneOffColor, ), - if (!props.isVideoEnabled) + if (!props.isVideoEnabled && + (style?.showVideoOffIcon ?? defaults.showVideoOffIcon)) Icon( context.streamIcons.videoOffFill, size: style?.videoOffIconSize ?? defaults.videoOffIconSize, @@ -206,6 +207,12 @@ class DefaultStreamParticipantLabel extends StatelessWidget { StreamAudioIndicator(isSpeaking: props.isSpeaking, style: style), ]; + final showsName = props.showName && props.name.isNotEmpty; + + // A participant with no name set, under a style drawing none of the + // indicators, leaves an empty pill sitting on the video. + if (!showsName && indicators.isEmpty) return const SizedBox.shrink(); + Widget content = Padding( padding: style?.padding ?? defaults.padding, child: Row( @@ -214,7 +221,7 @@ class DefaultStreamParticipantLabel extends StatelessWidget { // An empty name draws a zero-width Text that still claims the gap // before the indicators, leaving the pill padded for a name it is // not showing. A participant with no name set is not unusual. - if (props.showName && props.name.isNotEmpty) + if (showsName) // Flexible, not Expanded: the pill is only as wide as it needs to // be, up to whatever its parent allows. Combined with the parent's // bound this is what makes a long name ellipsize instead of diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart index 0a43bca62..a1e77f2ca 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart @@ -57,6 +57,9 @@ class StreamParticipantLabelStyleDefaults extends StreamParticipantLabelStyle { @override bool get showAudioIndicator => true; + @override + bool get showVideoOffIcon => true; + // Whatever the sound indicator would have made it, so a pill drawing // something shorter in its place is the size it would have been with it. @override @@ -161,7 +164,9 @@ double participantLabelMinWidth( final indicators = [ if (showMicrophoneOff) resolved?.microphoneIconSize ?? defaults.microphoneIconSize, - if (showVideoOff) resolved?.videoOffIconSize ?? defaults.videoOffIconSize, + if (showVideoOff && + (resolved?.showVideoOffIcon ?? defaults.showVideoOffIcon)) + resolved?.videoOffIconSize ?? defaults.videoOffIconSize, if (showVideoPaused) resolved?.videoPausedIconSize ?? defaults.videoPausedIconSize, // The sound indicator stands in for the microphone icon rather than diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 081db6b4d..d33a92e50 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -111,9 +111,11 @@ class _AndroidPipOverlayState extends State call: widget.call, participant: pipParticipant, style: const StreamParticipantTileStyle( - showParticipantLabel: false, - showConnectionQualityIndicator: false, showMoreButton: false, + labelStyle: StreamParticipantLabelStyle( + showAudioIndicator: false, + showVideoOffIcon: false, + ), ).merge(StreamPictureInPictureTheme.of(context).style?.tileStyle), ); } diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart index 7d773117f..d5ea6f207 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart @@ -129,6 +129,7 @@ class StreamParticipantLabelStyle with _$StreamParticipantLabelStyle { this.microphoneIconSize, this.microphoneOffColor, this.showAudioIndicator, + this.showVideoOffIcon, }); /// The pill's fill. @@ -257,6 +258,13 @@ class StreamParticipantLabelStyle with _$StreamParticipantLabelStyle { /// closed microphone for it to report. final bool? showAudioIndicator; + /// Whether to draw the camera-off icon. + /// + /// Defaults to true. Turn it off where the pill reports who a participant is + /// rather than what their devices are doing — the picture-in-picture window, + /// where the placeholder already stands in for the camera. + final bool? showVideoOffIcon; + /// Linearly interpolate between two styles. static StreamParticipantLabelStyle? lerp( StreamParticipantLabelStyle? a, diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart index 9a3cf814e..46205510f 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart @@ -156,6 +156,7 @@ mixin _$StreamParticipantLabelStyle { t, ), showAudioIndicator: t < 0.5 ? a.showAudioIndicator : b.showAudioIndicator, + showVideoOffIcon: t < 0.5 ? a.showVideoOffIcon : b.showVideoOffIcon, ); } @@ -181,6 +182,7 @@ mixin _$StreamParticipantLabelStyle { double? microphoneIconSize, Color? microphoneOffColor, bool? showAudioIndicator, + bool? showVideoOffIcon, }) { final _this = (this as StreamParticipantLabelStyle); @@ -209,6 +211,7 @@ mixin _$StreamParticipantLabelStyle { microphoneIconSize: microphoneIconSize ?? _this.microphoneIconSize, microphoneOffColor: microphoneOffColor ?? _this.microphoneOffColor, showAudioIndicator: showAudioIndicator ?? _this.showAudioIndicator, + showVideoOffIcon: showVideoOffIcon ?? _this.showVideoOffIcon, ); } @@ -247,6 +250,7 @@ mixin _$StreamParticipantLabelStyle { microphoneIconSize: other.microphoneIconSize, microphoneOffColor: other.microphoneOffColor, showAudioIndicator: other.showAudioIndicator, + showVideoOffIcon: other.showVideoOffIcon, ); } @@ -284,7 +288,8 @@ mixin _$StreamParticipantLabelStyle { _other.speakingColor == _this.speakingColor && _other.microphoneIconSize == _this.microphoneIconSize && _other.microphoneOffColor == _this.microphoneOffColor && - _other.showAudioIndicator == _this.showAudioIndicator; + _other.showAudioIndicator == _this.showAudioIndicator && + _other.showVideoOffIcon == _this.showVideoOffIcon; } @override @@ -314,6 +319,7 @@ mixin _$StreamParticipantLabelStyle { _this.microphoneIconSize, _this.microphoneOffColor, _this.showAudioIndicator, + _this.showVideoOffIcon, ]); } } diff --git a/packages/stream_video_flutter/test/src/call_participants/participant_label_test.dart b/packages/stream_video_flutter/test/src/call_participants/participant_label_test.dart new file mode 100644 index 000000000..a51d692ae --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/participant_label_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; + +final _icons = StreamTheme.light().icons; + +void main() { + Future pumpLabel( + WidgetTester tester, { + String name = 'Rene Floor', + bool isVideoEnabled = false, + bool isAudioEnabled = true, + StreamParticipantLabelStyle? style, + }) => tester.pumpWidget( + TestWrapper( + // Unbounded, so the pill comes out the size it asks for rather than the + // size of the surface. + child: Align( + alignment: Alignment.topLeft, + child: StreamParticipantLabel( + name: name, + isAudioEnabled: isAudioEnabled, + isSpeaking: false, + isVideoEnabled: isVideoEnabled, + style: const StreamParticipantLabelStyle( + blurSigma: 0, + ).merge(style), + ), + ), + ), + ); + + group('StreamParticipantLabel', () { + testWidgets('draws the camera-off icon while the camera is off', ( + tester, + ) async { + await pumpLabel(tester); + + expect(find.byIcon(_icons.videoOffFill), findsOneWidget); + }); + + testWidgets('leaves it out when the style switches it off', (tester) async { + await pumpLabel( + tester, + style: const StreamParticipantLabelStyle(showVideoOffIcon: false), + ); + + expect(find.byIcon(_icons.videoOffFill), findsNothing); + expect(find.text('Rene Floor'), findsOneWidget); + }); + + // A pill with nothing in it is a rounded rectangle of overlay sitting on + // the video. + testWidgets('draws nothing without a name or an indicator', (tester) async { + await pumpLabel( + tester, + name: '', + style: const StreamParticipantLabelStyle( + showVideoOffIcon: false, + showAudioIndicator: false, + ), + ); + + expect(find.byType(DefaultStreamParticipantLabel), findsOneWidget); + expect(tester.getSize(find.byType(StreamParticipantLabel)), Size.zero); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 7afd11e47..624eba262 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -90,36 +90,49 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('draws the participant without any tile chrome', ( + testWidgets('draws the name and the connection quality', (tester) async { + await pumpOverlay(tester); + + expect(find.text('renderer'), findsOneWidget); + expect(find.text('Rene Floor'), findsOneWidget); + expect(find.byType(StreamConnectionQualityIndicator), findsOneWidget); + }); + + testWidgets('draws no overflow button, camera icon or sound indicator', ( tester, ) async { + when(() => participant.isVideoEnabled).thenReturn(false); + await pumpOverlay(tester); - expect(find.text('renderer'), findsOneWidget); - expect(find.byType(StreamParticipantLabel), findsNothing); - expect(find.byType(StreamConnectionQualityIndicator), findsNothing); expect(find.byIcon(const StreamIcons().moreHorizontal), findsNothing); + expect(find.byIcon(const StreamIcons().videoOffFill), findsNothing); + expect(find.byType(StreamAudioIndicator), findsNothing); }); testWidgets('draws the chrome the picture-in-picture theme asks back', ( tester, ) async { + when(() => participant.isVideoEnabled).thenReturn(false); + await pumpOverlay( tester, pictureInPictureTheme: const StreamPictureInPictureThemeData( style: StreamPictureInPictureStyle( tileStyle: StreamParticipantTileStyle( - showParticipantLabel: true, - showConnectionQualityIndicator: true, showMoreButton: true, + labelStyle: StreamParticipantLabelStyle( + showAudioIndicator: true, + showVideoOffIcon: true, + ), ), ), ), ); - expect(find.byType(StreamParticipantLabel), findsOneWidget); - expect(find.byType(StreamConnectionQualityIndicator), findsOneWidget); expect(find.byIcon(const StreamIcons().moreHorizontal), findsOneWidget); + expect(find.byIcon(const StreamIcons().videoOffFill), findsOneWidget); + expect(find.byType(StreamAudioIndicator), findsOneWidget); }); }); } From 44b8da601ed106837d8121af8252d7d4b46ac395 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:25:47 +0200 Subject: [PATCH 05/10] fix(ui): square the participant in the picture-in-picture window The system rounds the window, so the tile's own corner clip left the Material behind it showing through as black wedges, and an outline drawn square had its corners clipped away, leaving a border down the sides. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 1 + .../android_pip_overlay.dart | 7 ++++ .../android_pip_overlay_test.dart | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 117b4764f..61e711438 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -159,6 +159,7 @@ - Fixed the participant grid rearranging itself when a participant nobody can see starts speaking. They take the place of the tile with the least claim to one — the last one on screen — instead of the first, which used to move every tile below it down one. - Fixed a participant tile on screen being recorded as not visible, which kept it out of the running for a speaker's tile and could get its track unsubscribed. A renderer showing a participant now says so again when the call state disagrees, and the floating self-view no longer shares its visibility bookkeeping with the same participant's tile in the grid. - The Android picture-in-picture window draws no overflow button, camera-off icon or sound indicator. It keeps the name pill and the connection quality indicator. +- The participant in the Android picture-in-picture window is drawn with square corners and no outline, so the rounded window no longer shows black wedges in its corners or a border down its sides. - The name pill draws nothing at all when it has neither a name nor an indicator, instead of an empty rounded rectangle over the video. - The floating self-view draws no name pill, whatever an app-wide participant tile theme asks for. `StreamFloatingParticipantTileStyle.tileStyle` still can. - A participant tile keeps the name in its label at every size it draws the label at, truncating with an ellipsis. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index d33a92e50..8d37ac686 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -111,6 +111,13 @@ class _AndroidPipOverlayState extends State call: widget.call, participant: pipParticipant, style: const StreamParticipantTileStyle( + // The window is rounded by the system, so a tile rounding itself + // as well leaves the Material behind it showing in the corners. + // Which also rules out an outline: it would be drawn square and + // then have its corners clipped away by the window. + borderRadius: BorderRadius.zero, + border: Border(), + showSpeakerBorder: false, showMoreButton: false, labelStyle: StreamParticipantLabelStyle( showAudioIndicator: false, diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 624eba262..314b59678 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -110,6 +110,45 @@ void main() { expect(find.byType(StreamAudioIndicator), findsNothing); }); + // The system rounds the window, and a tile rounding itself as well leaves + // the Material behind it showing through the corners. + testWidgets('draws square corners', (tester) async { + await pumpOverlay(tester); + + final clip = tester.widget( + find + .descendant( + of: find.byType(DefaultStreamParticipantTile), + matching: find.byType(ClipRRect), + ) + .first, + ); + + expect(clip.borderRadius, BorderRadius.zero); + }); + + // An outline runs into the same clip as the corners: drawn square, with the + // window cutting its corners off. Covers the speaking outline too, which + // is the one state that draws over video. + testWidgets('draws no outline, speaking or not', (tester) async { + when(() => participant.isVideoEnabled).thenReturn(false); + when(() => participant.isSpeaking).thenReturn(true); + + await pumpOverlay(tester); + + final container = tester.widget( + find + .descendant( + of: find.byType(DefaultStreamParticipantTile), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.foregroundDecoration as BoxDecoration?; + + expect(decoration?.border, const Border()); + }); + testWidgets('draws the chrome the picture-in-picture theme asks back', ( tester, ) async { From 90898795cdb6ea436709c7dff2a5125898c1d718 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:40:54 +0200 Subject: [PATCH 06/10] feat(ui): anchor the picture-in-picture chrome in the window's corners The name pill and the connection quality indicator sit flush against the window's edges, each square on the corner it occupies and rounded only on the inner one, which also gives the name the width the inset was taking. The indicator's shape comes off the decoration it would otherwise have drawn, so an app's own fill survives the change. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 1 + .../android_pip_overlay.dart | 72 ++++++++++++---- .../android_pip_overlay_test.dart | 84 ++++++++++++++++++- 3 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 61e711438..031183901 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -160,6 +160,7 @@ - Fixed a participant tile on screen being recorded as not visible, which kept it out of the running for a speaker's tile and could get its track unsubscribed. A renderer showing a participant now says so again when the call state disagrees, and the floating self-view no longer shares its visibility bookkeeping with the same participant's tile in the grid. - The Android picture-in-picture window draws no overflow button, camera-off icon or sound indicator. It keeps the name pill and the connection quality indicator. - The participant in the Android picture-in-picture window is drawn with square corners and no outline, so the rounded window no longer shows black wedges in its corners or a border down its sides. +- The name pill and the connection quality indicator sit in the corners of the Android picture-in-picture window, square on the corner each one occupies. - The name pill draws nothing at all when it has neither a name nor an indicator, instead of an empty rounded rectangle over the video. - The floating self-view draws no name pill, whatever an app-wide participant tile theme asks for. `StreamFloatingParticipantTileStyle.tileStyle` still can. - A participant tile keeps the name in its label at every size it draws the label at, truncating with an ellipsis. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 8d37ac686..df3a2ae7d 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../../stream_video_flutter.dart'; +import '../../../call_participants/indicators/connection_quality_indicator_defaults.dart'; import '../../../call_participants/screen_share_call_participants_content.dart'; /// A dedicated overlay widget for Android Picture-in-Picture mode. @@ -110,20 +111,7 @@ class _AndroidPipOverlayState extends State rendererScopePrefix: 'pipVideo', call: widget.call, participant: pipParticipant, - style: const StreamParticipantTileStyle( - // The window is rounded by the system, so a tile rounding itself - // as well leaves the Material behind it showing in the corners. - // Which also rules out an outline: it would be drawn square and - // then have its corners clipped away by the window. - borderRadius: BorderRadius.zero, - border: Border(), - showSpeakerBorder: false, - showMoreButton: false, - labelStyle: StreamParticipantLabelStyle( - showAudioIndicator: false, - showVideoOffIcon: false, - ), - ).merge(StreamPictureInPictureTheme.of(context).style?.tileStyle), + style: _pipTileStyle(context), ); } } @@ -135,4 +123,60 @@ class _AndroidPipOverlayState extends State ), ); } + + /// The tile the window draws, merged under + /// [StreamPictureInPictureStyle.tileStyle]. + StreamParticipantTileStyle _pipTileStyle(BuildContext context) { + final radius = context.streamRadius; + + // The chrome sits in the corners of the window, so the corner each piece + // occupies is square and only the inner one is rounded. Both styles take a + // plain BorderRadius, so which corner that is follows the text direction + // here: the toolbar puts the pill at the start and the indicator at the + // end. + final isRtl = Directionality.of(context) == TextDirection.rtl; + final cornerRadius = radius.lg; + final labelRadius = isRtl + ? BorderRadius.only(topLeft: cornerRadius) + : BorderRadius.only(topRight: cornerRadius); + final indicatorRadius = isRtl + ? BorderRadius.only(topRight: cornerRadius) + : BorderRadius.only(topLeft: cornerRadius); + + return StreamParticipantTileStyle( + // The window is rounded by the system, so a tile rounding itself as well + // leaves the Material behind it showing in the corners. Which also rules + // out an outline: it would be drawn square and then have its corners + // clipped away by the window. + borderRadius: BorderRadius.zero, + border: const Border(), + showSpeakerBorder: false, + showMoreButton: false, + // Flush into the window's own corners: at this size an inset costs more + // video than it buys in breathing room. + toolbarPadding: EdgeInsets.zero, + labelStyle: StreamParticipantLabelStyle( + showAudioIndicator: false, + showVideoOffIcon: false, + borderRadius: labelRadius, + ), + connectionQualityIndicatorStyle: StreamConnectionQualityIndicatorStyle( + // Only the shape changes, so it is taken off the decoration the + // indicator would have drawn — resolved the way the indicator resolves + // it — rather than described again here, which would drop an app's own + // fill. A rounded rectangle where the default is a circle, and + // BoxDecoration allows a radius on neither shape but the rectangle. + decoration: _indicatorDecoration( + context, + ).copyWith(shape: BoxShape.rectangle, borderRadius: indicatorRadius), + ), + ).merge(StreamPictureInPictureTheme.of(context).style?.tileStyle); + } + + /// The decoration the connection quality indicator would draw here. + BoxDecoration _indicatorDecoration(BuildContext context) { + final style = StreamConnectionQualityIndicatorTheme.of(context).style; + return style?.decoration ?? + StreamConnectionQualityIndicatorStyleDefaults(context).decoration; + } } diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 314b59678..30ba5fac3 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -52,7 +52,17 @@ void main() { Future pumpOverlay( WidgetTester tester, { StreamPictureInPictureThemeData? pictureInPictureTheme, + StreamConnectionQualityIndicatorThemeData? + connectionQualityIndicatorTheme, }) async { + final overlay = switch (pictureInPictureTheme) { + final theme? => StreamPictureInPictureTheme( + data: theme, + child: AndroidPipOverlay(call: call), + ), + null => AndroidPipOverlay(call: call), + }; + await tester.pumpWidget( StreamComponentFactory( builders: StreamComponentBuilders( @@ -76,12 +86,12 @@ void main() { child: SizedBox( width: 200, height: 300, - child: switch (pictureInPictureTheme) { - final theme? => StreamPictureInPictureTheme( + child: switch (connectionQualityIndicatorTheme) { + final theme? => StreamConnectionQualityIndicatorTheme( data: theme, - child: AndroidPipOverlay(call: call), + child: overlay, ), - null => AndroidPipOverlay(call: call), + null => overlay, }, ), ), @@ -149,6 +159,72 @@ void main() { expect(decoration?.border, const Border()); }); + // Both pieces of chrome sit in a corner of the window, so the corner each + // one occupies is square. + testWidgets('anchors the chrome in the corners', (tester) async { + await pumpOverlay(tester); + + final toolbarPadding = tester + .widgetList( + find.descendant( + of: find.byType(DefaultStreamParticipantTile), + matching: find.byType(Padding), + ), + ) + .map((it) => it.padding) + .toList(); + + expect(toolbarPadding, contains(EdgeInsets.zero)); + + final pill = tester.widget( + find + .descendant( + of: find.byType(DefaultStreamParticipantLabel), + matching: find.byType(ClipRRect), + ) + .first, + ); + expect( + pill.borderRadius, + BorderRadius.only(topRight: const StreamRadius().lg), + ); + }); + + // Only the shape is the window's business, so the fill an app themed the + // indicator with has to survive. + testWidgets('squares the indicator without dropping its fill', ( + tester, + ) async { + const themed = Color(0xFF00FF00); + + await pumpOverlay( + tester, + connectionQualityIndicatorTheme: + const StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle( + decoration: BoxDecoration(color: themed), + ), + ), + ); + + final box = tester.widget( + find + .descendant( + of: find.byType(StreamConnectionQualityIndicator), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = box.decoration as BoxDecoration; + + expect(decoration.color, themed); + expect(decoration.shape, BoxShape.rectangle); + expect( + decoration.borderRadius, + BorderRadius.only(topLeft: const StreamRadius().lg), + ); + }); + testWidgets('draws the chrome the picture-in-picture theme asks back', ( tester, ) async { From 2e007fdca9838981472aaf138aea0df00f4d6b59 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:47:14 +0200 Subject: [PATCH 07/10] refactor(ui): give the picture-in-picture chrome directional radii `StreamParticipantLabelStyle.borderRadius` takes a `BorderRadiusGeometry`, so a corner-anchored pill no longer resolves the text direction itself. The widget passes it to a `ClipRRect` and a `BoxDecoration`, both of which took one already. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 4 +--- .../participant_label_defaults.dart | 2 +- .../android_pip_overlay.dart | 18 +++++++----------- .../components/participant_label_theme.dart | 2 +- .../participant_label_theme.g.theme.dart | 8 ++++++-- .../android_pip_overlay_test.dart | 4 ++-- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 031183901..ef306558c 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -158,9 +158,7 @@ - Fixed the participant grid rearranging itself when a participant nobody can see starts speaking. They take the place of the tile with the least claim to one — the last one on screen — instead of the first, which used to move every tile below it down one. - Fixed a participant tile on screen being recorded as not visible, which kept it out of the running for a speaker's tile and could get its track unsubscribed. A renderer showing a participant now says so again when the call state disagrees, and the floating self-view no longer shares its visibility bookkeeping with the same participant's tile in the grid. -- The Android picture-in-picture window draws no overflow button, camera-off icon or sound indicator. It keeps the name pill and the connection quality indicator. -- The participant in the Android picture-in-picture window is drawn with square corners and no outline, so the rounded window no longer shows black wedges in its corners or a border down its sides. -- The name pill and the connection quality indicator sit in the corners of the Android picture-in-picture window, square on the corner each one occupies. +- The Android picture-in-picture window draws the name and the connection quality in its corners, and no other chrome. - The name pill draws nothing at all when it has neither a name nor an indicator, instead of an empty rounded rectangle over the video. - The floating self-view draws no name pill, whatever an app-wide participant tile theme asks for. `StreamFloatingParticipantTileStyle.tileStyle` still can. - A participant tile keeps the name in its label at every size it draws the label at, truncating with an ellipsis. diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart index a1e77f2ca..93e0decd3 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart @@ -35,7 +35,7 @@ class StreamParticipantLabelStyleDefaults extends StreamParticipantLabelStyle { Color get backgroundColor => _colorScheme.backgroundOverlayDarkStrong; @override - BorderRadius get borderRadius => BorderRadius.all(_radius.lg); + BorderRadiusGeometry get borderRadius => BorderRadius.all(_radius.lg); @override EdgeInsetsGeometry get padding => EdgeInsetsDirectional.fromSTEB( diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index df3a2ae7d..2b26906da 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -130,18 +130,14 @@ class _AndroidPipOverlayState extends State final radius = context.streamRadius; // The chrome sits in the corners of the window, so the corner each piece - // occupies is square and only the inner one is rounded. Both styles take a - // plain BorderRadius, so which corner that is follows the text direction - // here: the toolbar puts the pill at the start and the indicator at the - // end. - final isRtl = Directionality.of(context) == TextDirection.rtl; + // occupies is square and only the inner one is rounded. Directional: the + // toolbar puts the pill at the start and the indicator at the end, and + // which corner each of those is comes out in the layout. final cornerRadius = radius.lg; - final labelRadius = isRtl - ? BorderRadius.only(topLeft: cornerRadius) - : BorderRadius.only(topRight: cornerRadius); - final indicatorRadius = isRtl - ? BorderRadius.only(topRight: cornerRadius) - : BorderRadius.only(topLeft: cornerRadius); + final labelRadius = BorderRadiusDirectional.only(topEnd: cornerRadius); + final indicatorRadius = BorderRadiusDirectional.only( + topStart: cornerRadius, + ); return StreamParticipantTileStyle( // The window is rounded by the system, so a tile rounding itself as well diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart index d5ea6f207..25efada48 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart @@ -141,7 +141,7 @@ class StreamParticipantLabelStyle with _$StreamParticipantLabelStyle { /// The pill's corner radius. /// /// Defaults to `radius.lg`. - final BorderRadius? borderRadius; + final BorderRadiusGeometry? borderRadius; /// The inset around the pill's content. /// diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart index 46205510f..7907ffa6b 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart @@ -104,7 +104,11 @@ mixin _$StreamParticipantLabelStyle { return StreamParticipantLabelStyle( backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), + borderRadius: BorderRadiusGeometry.lerp( + a.borderRadius, + b.borderRadius, + t, + ), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), spacing: lerpDouble$(a.spacing, b.spacing, t), indicatorSpacing: lerpDouble$(a.indicatorSpacing, b.indicatorSpacing, t), @@ -162,7 +166,7 @@ mixin _$StreamParticipantLabelStyle { StreamParticipantLabelStyle copyWith({ Color? backgroundColor, - BorderRadius? borderRadius, + BorderRadiusGeometry? borderRadius, EdgeInsetsGeometry? padding, double? spacing, double? indicatorSpacing, diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 30ba5fac3..0e8eae144 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -186,7 +186,7 @@ void main() { ); expect( pill.borderRadius, - BorderRadius.only(topRight: const StreamRadius().lg), + BorderRadiusDirectional.only(topEnd: const StreamRadius().lg), ); }); @@ -221,7 +221,7 @@ void main() { expect(decoration.shape, BoxShape.rectangle); expect( decoration.borderRadius, - BorderRadius.only(topLeft: const StreamRadius().lg), + BorderRadiusDirectional.only(topStart: const StreamRadius().lg), ); }); From 1169a0dfd8bd49abecb184dd7febf6c29e33173c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:53:52 +0200 Subject: [PATCH 08/10] test(ui): cover the picture-in-picture theme The theme's accessor, wrap, updateShouldNotify and lerp, and the branch StreamVideoTheme.lerp carries it through. Co-Authored-By: Claude Opus 5 --- .../theme/picture_in_picture_theme_test.dart | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/stream_video_flutter/test/src/theme/picture_in_picture_theme_test.dart diff --git a/packages/stream_video_flutter/test/src/theme/picture_in_picture_theme_test.dart b/packages/stream_video_flutter/test/src/theme/picture_in_picture_theme_test.dart new file mode 100644 index 000000000..8c801e3bd --- /dev/null +++ b/packages/stream_video_flutter/test/src/theme/picture_in_picture_theme_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; + +void main() { + StreamVideoTheme themeWith(StreamParticipantTileStyle tileStyle) => + StreamVideoTheme.light().copyWith( + pictureInPictureTheme: StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle(tileStyle: tileStyle), + ), + ); + + Widget app({required StreamVideoTheme theme, required Widget home}) => + MaterialApp( + theme: streamTestTheme().copyWith( + extensions: [StreamTheme.light(), theme], + ), + home: home, + ); + + group('StreamPictureInPictureTheme', () { + testWidgets('resolves the global theme when no ancestor is present', ( + tester, + ) async { + late StreamPictureInPictureThemeData resolved; + + await tester.pumpWidget( + app( + theme: themeWith( + const StreamParticipantTileStyle(showParticipantLabel: false), + ), + home: Builder( + builder: (context) { + resolved = StreamPictureInPictureTheme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(resolved.style?.tileStyle?.showParticipantLabel, isFalse); + }); + + testWidgets('merges a local override over the global theme', ( + tester, + ) async { + late StreamPictureInPictureThemeData resolved; + + await tester.pumpWidget( + app( + theme: themeWith( + const StreamParticipantTileStyle( + showParticipantLabel: false, + showMoreButton: false, + ), + ), + home: StreamPictureInPictureTheme( + data: const StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle(showMoreButton: true), + ), + ), + child: Builder( + builder: (context) { + resolved = StreamPictureInPictureTheme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + // The local value wins, and the global one it did not mention survives. + expect(resolved.style?.tileStyle?.showMoreButton, isTrue); + expect(resolved.style?.tileStyle?.showParticipantLabel, isFalse); + }); + + testWidgets('wrap carries the theme into another subtree', (tester) async { + const data = StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle(showMoreButton: true), + ), + ); + + late StreamPictureInPictureThemeData resolved; + + await tester.pumpWidget( + app( + theme: StreamVideoTheme.light(), + home: Builder( + builder: (context) => + const StreamPictureInPictureTheme( + data: data, + child: SizedBox.shrink(), + ).wrap( + context, + Builder( + builder: (context) { + resolved = StreamPictureInPictureTheme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + expect(resolved.style?.tileStyle?.showMoreButton, isTrue); + }); + + test('updateShouldNotify follows the data', () { + const a = StreamPictureInPictureTheme( + data: StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle(showMoreButton: true), + ), + ), + child: SizedBox.shrink(), + ); + const same = StreamPictureInPictureTheme( + data: StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle(showMoreButton: true), + ), + ), + child: SizedBox.shrink(), + ); + const other = StreamPictureInPictureTheme( + data: StreamPictureInPictureThemeData(), + child: SizedBox.shrink(), + ); + + expect(a.updateShouldNotify(same), isFalse); + expect(a.updateShouldNotify(other), isTrue); + }); + + test('lerp interpolates the tile style it carries', () { + const a = StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle( + labelStyle: StreamParticipantLabelStyle(blurSigma: 0), + ), + ), + ); + const b = StreamPictureInPictureThemeData( + style: StreamPictureInPictureStyle( + tileStyle: StreamParticipantTileStyle( + labelStyle: StreamParticipantLabelStyle(blurSigma: 10), + ), + ), + ); + + final mid = StreamPictureInPictureThemeData.lerp(a, b, 0.5); + + expect(mid?.style?.tileStyle?.labelStyle?.blurSigma, 5); + }); + }); + + group('StreamVideoTheme', () { + test('carries the picture-in-picture theme through copyWith', () { + final theme = themeWith( + const StreamParticipantTileStyle(showConnectionQualityIndicator: false), + ); + + expect( + theme + .pictureInPictureTheme + .style + ?.tileStyle + ?.showConnectionQualityIndicator, + isFalse, + ); + }); + + test('defaults the picture-in-picture theme to an empty instance', () { + expect(StreamVideoTheme.light().pictureInPictureTheme.style, isNull); + }); + + test('lerp interpolates the picture-in-picture theme', () { + final a = themeWith( + const StreamParticipantTileStyle( + labelStyle: StreamParticipantLabelStyle(blurSigma: 0), + ), + ); + final b = themeWith( + const StreamParticipantTileStyle( + labelStyle: StreamParticipantLabelStyle(blurSigma: 10), + ), + ); + + final mid = a.lerp(b, 0.5) as StreamVideoTheme; + + expect( + mid.pictureInPictureTheme.style?.tileStyle?.labelStyle?.blurSigma, + 5, + ); + }); + }); +} From 61c3d059250df6542cba122b6f0d950e109007b8 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:58:47 +0200 Subject: [PATCH 09/10] test(ui): snapshot the picture-in-picture window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the size Android gives it — 128x228dp — over video, with the camera off, muted and on a poor connection. No golden rendered the window before, so nothing in the suite noticed what its chrome did. The style it draws with moves to `pictureInPictureTileStyle`, internal and unexported, so the snapshot and the widget tests assert the shape the window actually uses rather than restating it. Co-Authored-By: Claude Opus 5 --- .../android_pip_overlay.dart | 58 +-------- .../picture_in_picture_defaults.dart | 58 +++++++++ .../android_pip_overlay_golden_test.dart | 110 ++++++++++++++++++ .../android_pip_overlay_test.dart | 20 +++- 4 files changed, 188 insertions(+), 58 deletions(-) create mode 100644 packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_defaults.dart create mode 100644 packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_golden_test.dart diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 2b26906da..4b6b58821 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -4,8 +4,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../../stream_video_flutter.dart'; -import '../../../call_participants/indicators/connection_quality_indicator_defaults.dart'; import '../../../call_participants/screen_share_call_participants_content.dart'; +import 'picture_in_picture_defaults.dart'; /// A dedicated overlay widget for Android Picture-in-Picture mode. /// This widget creates a floating overlay that shows only the video content @@ -111,7 +111,9 @@ class _AndroidPipOverlayState extends State rendererScopePrefix: 'pipVideo', call: widget.call, participant: pipParticipant, - style: _pipTileStyle(context), + style: pictureInPictureTileStyle(context).merge( + StreamPictureInPictureTheme.of(context).style?.tileStyle, + ), ); } } @@ -123,56 +125,4 @@ class _AndroidPipOverlayState extends State ), ); } - - /// The tile the window draws, merged under - /// [StreamPictureInPictureStyle.tileStyle]. - StreamParticipantTileStyle _pipTileStyle(BuildContext context) { - final radius = context.streamRadius; - - // The chrome sits in the corners of the window, so the corner each piece - // occupies is square and only the inner one is rounded. Directional: the - // toolbar puts the pill at the start and the indicator at the end, and - // which corner each of those is comes out in the layout. - final cornerRadius = radius.lg; - final labelRadius = BorderRadiusDirectional.only(topEnd: cornerRadius); - final indicatorRadius = BorderRadiusDirectional.only( - topStart: cornerRadius, - ); - - return StreamParticipantTileStyle( - // The window is rounded by the system, so a tile rounding itself as well - // leaves the Material behind it showing in the corners. Which also rules - // out an outline: it would be drawn square and then have its corners - // clipped away by the window. - borderRadius: BorderRadius.zero, - border: const Border(), - showSpeakerBorder: false, - showMoreButton: false, - // Flush into the window's own corners: at this size an inset costs more - // video than it buys in breathing room. - toolbarPadding: EdgeInsets.zero, - labelStyle: StreamParticipantLabelStyle( - showAudioIndicator: false, - showVideoOffIcon: false, - borderRadius: labelRadius, - ), - connectionQualityIndicatorStyle: StreamConnectionQualityIndicatorStyle( - // Only the shape changes, so it is taken off the decoration the - // indicator would have drawn — resolved the way the indicator resolves - // it — rather than described again here, which would drop an app's own - // fill. A rounded rectangle where the default is a circle, and - // BoxDecoration allows a radius on neither shape but the rectangle. - decoration: _indicatorDecoration( - context, - ).copyWith(shape: BoxShape.rectangle, borderRadius: indicatorRadius), - ), - ).merge(StreamPictureInPictureTheme.of(context).style?.tileStyle); - } - - /// The decoration the connection quality indicator would draw here. - BoxDecoration _indicatorDecoration(BuildContext context) { - final style = StreamConnectionQualityIndicatorTheme.of(context).style; - return style?.decoration ?? - StreamConnectionQualityIndicatorStyleDefaults(context).decoration; - } } diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_defaults.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_defaults.dart new file mode 100644 index 000000000..ad05526d9 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_defaults.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../stream_video_flutter.dart'; +import '../../../call_participants/indicators/connection_quality_indicator_defaults.dart'; + +/// The participant tile style the picture-in-picture window draws with, before +/// [StreamPictureInPictureStyle.tileStyle] is merged over it. +/// +/// Shared with the tests that assert what the window draws, so the shape they +/// check is the one it uses. Deliberately not exported. +@internal +StreamParticipantTileStyle pictureInPictureTileStyle(BuildContext context) { + final radius = context.streamRadius; + + // The chrome sits in the corners of the window, so the corner each piece + // occupies is square and only the inner one is rounded. Directional: the + // toolbar puts the pill at the start and the indicator at the end, and which + // corner each of those is comes out in the layout. + final cornerRadius = radius.lg; + + return StreamParticipantTileStyle( + // The window is rounded by the system, so a tile rounding itself as well + // leaves the Material behind it showing in the corners. Which also rules + // out an outline: it would be drawn square and then have its corners + // clipped away by the window. + borderRadius: BorderRadius.zero, + border: const Border(), + showSpeakerBorder: false, + showMoreButton: false, + // Flush into the window's own corners: at this size an inset costs more + // video than it buys in breathing room. + toolbarPadding: EdgeInsets.zero, + labelStyle: StreamParticipantLabelStyle( + showAudioIndicator: false, + showVideoOffIcon: false, + borderRadius: BorderRadiusDirectional.only(topEnd: cornerRadius), + ), + connectionQualityIndicatorStyle: StreamConnectionQualityIndicatorStyle( + // Only the shape changes, so it is taken off the decoration the indicator + // would have drawn — resolved the way the indicator resolves it — rather + // than described again here, which would drop an app's own fill. A + // rounded rectangle where the default is a circle, and BoxDecoration + // allows a radius on neither shape but the rectangle. + decoration: _indicatorDecoration(context).copyWith( + shape: BoxShape.rectangle, + borderRadius: BorderRadiusDirectional.only(topStart: cornerRadius), + ), + ), + ); +} + +/// The decoration the connection quality indicator would draw here. +BoxDecoration _indicatorDecoration(BuildContext context) { + final style = StreamConnectionQualityIndicatorTheme.of(context).style; + return style?.decoration ?? + StreamConnectionQualityIndicatorStyleDefaults(context).decoration; +} diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_golden_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_golden_test.dart new file mode 100644 index 000000000..4bc131056 --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_golden_test.dart @@ -0,0 +1,110 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../../../test_utils/goldens.dart'; +import '../../../mocks.dart'; + +MockCallParticipantState _participant({ + String name = 'Katie Miler', + bool isAudioEnabled = true, + bool isVideoEnabled = true, + SfuConnectionQuality quality = SfuConnectionQuality.excellent, +}) { + final participant = MockCallParticipantState(); + when(() => participant.userId).thenReturn('katie'); + when(() => participant.uniqueParticipantKey).thenReturn('katie-session'); + when(() => participant.name).thenReturn(name); + when(() => participant.image).thenReturn(null); + when(() => participant.isLocal).thenReturn(false); + when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isAudioEnabled).thenReturn(isAudioEnabled); + when(() => participant.isVideoEnabled).thenReturn(isVideoEnabled); + when(() => participant.isScreenShareEnabled).thenReturn(false); + when(() => participant.screenShareTrack).thenReturn(null); + when(() => participant.connectionQuality).thenReturn(quality); + when(() => participant.reaction).thenReturn(null); + // What the avatar placeholder draws from. + when(participant.toUserInfo).thenReturn(UserInfo(id: 'katie', name: name)); + return participant; +} + +Widget _window(CallParticipantState participant) { + final call = MockCall(); + final state = MockCallState(); + + when(() => state.callParticipants).thenReturn([participant]); + when( + () => call.state, + ).thenAnswer((_) => MutableStateEmitter(state, sync: true)); + when(() => call.partialState>(any())).thenAnswer(( + invocation, + ) { + final CallStateSelector> selector = + invocation.positionalArguments[0]; + return Stream.value(selector(state)); + }); + + return StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + // A real renderer needs a live call. A flat fill stands in for video, + // and keeps the snapshot off a decoded frame. The placeholder is the + // real one: replacing the renderer wholesale would take it with it, + // and it is what the window shows with the camera off. + participantVideo: (context, props) => props.participant.isVideoEnabled + ? const ColoredBox(color: Color(0xFF6E7A8A)) + : StreamParticipantPlaceholder( + call: props.call, + participant: props.participant, + ), + ), + ), + child: AndroidPipOverlay(call: call), + ); +} + +// The window as Android draws it, at the size it gave the dogfooding app on a +// Pixel 8: 128x228dp, which is `full` density on the tile's ladder. +// +// What cannot be snapshotted: the pill's backdrop filter is a no-op under +// `flutter test`, so its fill comes out flat rather than blurred, and the +// window's own rounded corners belong to the system rather than to this +// subtree — the tile deliberately draws square into them. +void main() { + for (final brightness in Brightness.values) { + streamGoldenTest( + 'AndroidPipOverlay renders the window', + fileName: 'android_pip_overlay', + brightness: brightness, + builder: () => GoldenTestGroup( + columns: 4, + scenarioConstraints: const BoxConstraints.tightFor( + width: 128, + height: 228, + ), + children: [ + GoldenTestScenario( + name: 'video on', + child: _window(_participant()), + ), + GoldenTestScenario( + name: 'camera off', + child: _window(_participant(isVideoEnabled: false)), + ), + GoldenTestScenario( + name: 'muted', + child: _window(_participant(isAudioEnabled: false)), + ), + GoldenTestScenario( + name: 'poor connection', + child: _window( + _participant(quality: SfuConnectionQuality.poor), + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart index 0e8eae144..c65aed0ba 100644 --- a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/src/call_screen/call_content/picture_in_picture/picture_in_picture_defaults.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; import '../../../../test_utils/test_wrapper.dart'; @@ -164,6 +165,10 @@ void main() { testWidgets('anchors the chrome in the corners', (tester) async { await pumpOverlay(tester); + final expected = pictureInPictureTileStyle( + tester.element(find.byType(DefaultStreamParticipantTile)), + ); + final toolbarPadding = tester .widgetList( find.descendant( @@ -174,7 +179,7 @@ void main() { .map((it) => it.padding) .toList(); - expect(toolbarPadding, contains(EdgeInsets.zero)); + expect(toolbarPadding, contains(expected.toolbarPadding)); final pill = tester.widget( find @@ -184,9 +189,14 @@ void main() { ) .first, ); + expect(pill.borderRadius, expected.labelStyle?.borderRadius); + // Whatever the radius is, it is on the pill's inner corner alone. expect( - pill.borderRadius, - BorderRadiusDirectional.only(topEnd: const StreamRadius().lg), + pill.borderRadius.resolve(TextDirection.ltr), + isA() + .having((it) => it.topLeft, 'topLeft', Radius.zero) + .having((it) => it.topRight, 'topRight', isNot(Radius.zero)) + .having((it) => it.bottomLeft, 'bottomLeft', Radius.zero), ); }); @@ -221,7 +231,9 @@ void main() { expect(decoration.shape, BoxShape.rectangle); expect( decoration.borderRadius, - BorderRadiusDirectional.only(topStart: const StreamRadius().lg), + pictureInPictureTileStyle( + tester.element(find.byType(DefaultStreamParticipantTile)), + ).connectionQualityIndicatorStyle?.decoration?.borderRadius, ); }); From 4d07aaba01e43b97e2d87a5277c81d4304ae512b Mon Sep 17 00:00:00 2001 From: renefloor <15101411+renefloor@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:37 +0000 Subject: [PATCH 10/10] chore: update goldens --- .../goldens/ci/android_pip_overlay_dark.png | Bin 0 -> 7506 bytes .../goldens/ci/android_pip_overlay_light.png | Bin 0 -> 7136 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_dark.png create mode 100644 packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_light.png diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_dark.png b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1772e18c997256e83233170d55eaae55a1edfd17 GIT binary patch literal 7506 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 TI!m3w3jkDEOR47Bi^%@~Sho{? literal 0 HcmV?d00001 diff --git a/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_light.png b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_light.png new file mode 100644 index 0000000000000000000000000000000000000000..10a911c472ad293da8dd10af625e78566a5cb964 GIT binary patch literal 7136 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%0D1yYQ_e=7S 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 EHx%h9#{d8T literal 0 HcmV?d00001