diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9c70a21c9..ef306558c 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,6 +2,8 @@ ### ✅ 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`. @@ -156,6 +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 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. - 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..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( @@ -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 121806071..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 @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../../stream_video_flutter.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 @@ -110,6 +111,9 @@ class _AndroidPipOverlayState extends State rendererScopePrefix: 'pipVideo', call: widget.call, participant: pipParticipant, + style: pictureInPictureTileStyle(context).merge( + StreamPictureInPictureTheme.of(context).style?.tileStyle, + ), ); } } 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/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/participant_label_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart index 7d773117f..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 @@ -129,6 +129,7 @@ class StreamParticipantLabelStyle with _$StreamParticipantLabelStyle { this.microphoneIconSize, this.microphoneOffColor, this.showAudioIndicator, + this.showVideoOffIcon, }); /// The pill's fill. @@ -140,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. /// @@ -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..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), @@ -156,12 +160,13 @@ mixin _$StreamParticipantLabelStyle { t, ), showAudioIndicator: t < 0.5 ? a.showAudioIndicator : b.showAudioIndicator, + showVideoOffIcon: t < 0.5 ? a.showVideoOffIcon : b.showVideoOffIcon, ); } StreamParticipantLabelStyle copyWith({ Color? backgroundColor, - BorderRadius? borderRadius, + BorderRadiusGeometry? borderRadius, EdgeInsetsGeometry? padding, double? spacing, double? indicatorSpacing, @@ -181,6 +186,7 @@ mixin _$StreamParticipantLabelStyle { double? microphoneIconSize, Color? microphoneOffColor, bool? showAudioIndicator, + bool? showVideoOffIcon, }) { final _this = (this as StreamParticipantLabelStyle); @@ -209,6 +215,7 @@ mixin _$StreamParticipantLabelStyle { microphoneIconSize: microphoneIconSize ?? _this.microphoneIconSize, microphoneOffColor: microphoneOffColor ?? _this.microphoneOffColor, showAudioIndicator: showAudioIndicator ?? _this.showAudioIndicator, + showVideoOffIcon: showVideoOffIcon ?? _this.showVideoOffIcon, ); } @@ -247,6 +254,7 @@ mixin _$StreamParticipantLabelStyle { microphoneIconSize: other.microphoneIconSize, microphoneOffColor: other.microphoneOffColor, showAudioIndicator: other.showAudioIndicator, + showVideoOffIcon: other.showVideoOffIcon, ); } @@ -284,7 +292,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 +323,7 @@ mixin _$StreamParticipantLabelStyle { _this.microphoneIconSize, _this.microphoneOffColor, _this.showAudioIndicator, + _this.showVideoOffIcon, ]); } } 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..f35388288 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/picture_in_picture_theme.dart @@ -0,0 +1,103 @@ +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], above the route showing +/// the call. Set [StreamVideoTheme.pictureInPictureTheme], or wrap this above +/// the [Navigator], to reach it. +/// +/// 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. The iOS window is rendered natively +/// and is configured through `IOSPictureInPictureConfiguration`. +@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 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. + /// + /// Has no effect on a window built by + /// `AndroidPictureInPictureConfiguration.callPictureInPictureWidgetBuilder`. + 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_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_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 new file mode 100644 index 000000000..c65aed0ba --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/android_pip_overlay_test.dart @@ -0,0 +1,265 @@ +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'; +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 registered tile builder adds an overflow menu to every tile, the way + // the dogfooding app does. + 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( + 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: switch (connectionQualityIndicatorTheme) { + final theme? => StreamConnectionQualityIndicatorTheme( + data: theme, + child: overlay, + ), + null => overlay, + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + 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.byIcon(const StreamIcons().moreHorizontal), findsNothing); + expect(find.byIcon(const StreamIcons().videoOffFill), findsNothing); + 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()); + }); + + // 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 expected = pictureInPictureTileStyle( + tester.element(find.byType(DefaultStreamParticipantTile)), + ); + + final toolbarPadding = tester + .widgetList( + find.descendant( + of: find.byType(DefaultStreamParticipantTile), + matching: find.byType(Padding), + ), + ) + .map((it) => it.padding) + .toList(); + + expect(toolbarPadding, contains(expected.toolbarPadding)); + + final pill = tester.widget( + find + .descendant( + of: find.byType(DefaultStreamParticipantLabel), + matching: find.byType(ClipRRect), + ) + .first, + ); + expect(pill.borderRadius, expected.labelStyle?.borderRadius); + // Whatever the radius is, it is on the pill's inner corner alone. + expect( + 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), + ); + }); + + // 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, + pictureInPictureTileStyle( + tester.element(find.byType(DefaultStreamParticipantTile)), + ).connectionQualityIndicatorStyle?.decoration?.borderRadius, + ); + }); + + 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( + showMoreButton: true, + labelStyle: StreamParticipantLabelStyle( + showAudioIndicator: true, + showVideoOffIcon: true, + ), + ), + ), + ), + ); + + expect(find.byIcon(const StreamIcons().moreHorizontal), findsOneWidget); + expect(find.byIcon(const StreamIcons().videoOffFill), findsOneWidget); + expect(find.byType(StreamAudioIndicator), findsOneWidget); + }); + }); +} 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 000000000..1772e18c9 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_dark.png differ 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 000000000..10a911c47 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_screen/call_content/picture_in_picture/goldens/ci/android_pip_overlay_light.png differ 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, + ); + }); + }); +}