diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 2d11901f7..513bf019c 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## Upcoming + +### ✅ Added +* Added `prioritiseScreenSharingTrack` parameter to `PictureInPictureConfiguration` to control whether screen sharing or camera track is preferred in PiP mode. When set to `false`, the camera track is preferred, but screen share will still be shown as a fallback if the camera is disabled. + ## 1.0.2 🐞 Fixed diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart index 12c8ec391..628fc464c 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart @@ -109,27 +109,36 @@ class StreamCallParticipants extends StatefulWidget { State createState() => _StreamCallParticipantsState(); } -class _StreamCallParticipantsState extends State { - List _participants = []; - CallParticipantState? _screenShareParticipant; +class _StreamCallParticipantsState extends State + with CallParticipantsSortingMixin { + StreamSubscription?>? _participantsSubscription; - List _sortedParticipantKeys = []; + @override + Filter get participantFilter => widget.filter; - StreamSubscription?>? _participantsSubscription; + @override + Sort get participantSort => widget.sort; @override void initState() { super.initState(); - _recalculateParticipants( + recalculateParticipants( widget.participants ?? widget.call.state.value.callParticipants, ); + if (widget.participants == null) { _participantsSubscription = widget.call .partialState((state) => state.callParticipants) - .listen(_recalculateParticipants); + .listen(recalculateParticipants); } } + @override + void dispose() { + _participantsSubscription?.cancel(); + super.dispose(); + } + @override void didUpdateWidget(covariant StreamCallParticipants oldWidget) { super.didUpdateWidget(oldWidget); @@ -141,86 +150,25 @@ class _StreamCallParticipantsState extends State { widget.participants!.toList(), oldWidget.participants?.toList(), )) { - _recalculateParticipants(widget.participants!); + recalculateParticipants(widget.participants!); } } else if (widget.call != oldWidget.call) { _participantsSubscription?.cancel(); _participantsSubscription = widget.call .partialState((state) => state.callParticipants) - .listen(_recalculateParticipants); - } - } - - void _recalculateParticipants(List newParticipants) { - final participants = [...newParticipants].where(widget.filter).toList(); - - for (final participant in participants) { - final index = _sortedParticipantKeys.indexOf( - participant.uniqueParticipantKey, - ); - if (index == -1) { - _sortedParticipantKeys.add(participant.uniqueParticipantKey); - } - } - - // First apply previous sorting on new participants list - participants.sort( - (a, b) => _sortedParticipantKeys - .indexOf(a.uniqueParticipantKey) - .compareTo(_sortedParticipantKeys.indexOf(b.uniqueParticipantKey)), - ); - - mergeSort(participants, compare: widget.sort); - - final screenShareParticipant = participants.firstWhereOrNull( - (it) { - final screenShareTrack = it.screenShareTrack; - final isScreenShareEnabled = it.isScreenShareEnabled; - - if (screenShareTrack == null || !isScreenShareEnabled) return false; - - return true; - }, - ); - - _sortedParticipantKeys = participants - .map((e) => e.uniqueParticipantKey) - .toList(); + .listen(recalculateParticipants); - if (mounted) { - setState(() { - _participants = participants.toList(); - _screenShareParticipant = screenShareParticipant; - }); + recalculateParticipants(widget.call.state.value.callParticipants); } } @override Widget build(BuildContext context) { - if (_participants.isNotEmpty && - widget.layoutMode == ParticipantLayoutMode.pictureInPicture) { - if (_screenShareParticipant != null) { - return ScreenShareContent( - key: ValueKey( - '${_screenShareParticipant!.uniqueParticipantKey} - screenShareContent', - ), - call: widget.call, - participant: _screenShareParticipant!, - ); - } - - return widget.callParticipantBuilder( - context, - widget.call, - _participants.first, - ); - } - - if (_screenShareParticipant != null) { + if (screenShareParticipant != null) { return ScreenShareCallParticipantsContent( call: widget.call, - participants: _participants, - screenSharingParticipant: _screenShareParticipant!, + participants: sortedParticipants, + screenSharingParticipant: screenShareParticipant!, screenShareContentBuilder: widget.screenShareContentBuilder, screenShareParticipantBuilder: widget.screenShareParticipantBuilder, ); @@ -228,7 +176,7 @@ class _StreamCallParticipantsState extends State { return RegularCallParticipantsContent( call: widget.call, - participants: _participants, + participants: sortedParticipants, layoutMode: widget.layoutMode, enableLocalVideo: widget.enableLocalVideo, callParticipantBuilder: widget.callParticipantBuilder, diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart new file mode 100644 index 000000000..1fa113002 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart @@ -0,0 +1,81 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_video/stream_video.dart'; + +import 'call_participants.dart'; + +/// A mixin that provides participant sorting and filtering logic. +mixin CallParticipantsSortingMixin on State { + List get sortedParticipants => _participants; + List _participants = []; + + CallParticipantState? get screenShareParticipant => _screenShareParticipant; + CallParticipantState? _screenShareParticipant; + + /// Keys used to maintain stable participant ordering across updates. + List _sortedParticipantKeys = []; + + /// The filter function to apply to participants. + /// + /// Override this getter to provide the filter from your widget. + Filter? get participantFilter; + + /// The sort comparator to apply to participants. + /// + /// Override this getter to provide the sort from your widget. + Sort? get participantSort; + + /// Call this method whenever the participant list changes, typically from + /// a stream subscription or in [didUpdateWidget]. + void recalculateParticipants(List newParticipants) { + final participants = [ + ...newParticipants, + ].where(participantFilter ?? (_) => true).toList(); + + for (final participant in participants) { + final index = _sortedParticipantKeys.indexOf( + participant.uniqueParticipantKey, + ); + if (index == -1) { + _sortedParticipantKeys.add(participant.uniqueParticipantKey); + } + } + + // First apply previous sorting on new participants list + participants.sort( + (a, b) => _sortedParticipantKeys + .indexOf(a.uniqueParticipantKey) + .compareTo(_sortedParticipantKeys.indexOf(b.uniqueParticipantKey)), + ); + + if (participantSort != null) { + mergeSort(participants, compare: participantSort); + } + + final screenShareParticipant = participants.firstWhereOrNull( + (it) { + final screenShareTrack = it.screenShareTrack; + final isScreenShareEnabled = it.isScreenShareEnabled; + + if (screenShareTrack == null || !isScreenShareEnabled) return false; + + return true; + }, + ); + + _sortedParticipantKeys = participants + .map((e) => e.uniqueParticipantKey) + .toList(); + + if (mounted) { + setState(() { + _participants = participants.toList(); + _screenShareParticipant = screenShareParticipant; + }); + } + } + + void clearParticipantSortingCache() { + _sortedParticipantKeys = []; + } +} diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/call_content.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/call_content.dart index 7ea5ddc2f..e51bdabe0 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/call_content.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/call_content.dart @@ -143,9 +143,8 @@ class _StreamCallContentState extends State { width: 300, child: StreamPictureInPictureUiKitView( call: call, - configuration: - widget.pictureInPictureConfiguration.iOSPiPConfiguration, - participantSort: widget.pictureInPictureConfiguration.sort, + pictureInPictureConfiguration: + widget.pictureInPictureConfiguration, ), ), if (CurrentPlatform.isAndroid && pipEnabled) 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 2deb9c357..c59637bcc 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 @@ -1,5 +1,10 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../stream_video_flutter.dart'; +import '../../../call_participants/screen_share_call_participants_content.dart'; /// A dedicated overlay widget for Android Picture-in-Picture mode. /// This widget creates a floating overlay that shows only the video content @@ -8,31 +13,106 @@ class AndroidPipOverlay extends StatefulWidget { const AndroidPipOverlay({ super.key, required this.call, + this.pictureInPictureConfiguration, + @Deprecated( + 'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead', + ) this.sort, + @Deprecated( + 'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead', + ) this.customBuilder, }); final Call call; - final Comparator? sort; + final PictureInPictureConfiguration? pictureInPictureConfiguration; + @Deprecated('Use [pictureInPictureConfiguration.sort] instead') + final Sort? sort; + @Deprecated( + 'Use [pictureInPictureConfiguration.androidPiPConfiguration.customBuilder] instead', + ) final CallWidgetBuilder? customBuilder; @override State createState() => _AndroidPipOverlayState(); } -class _AndroidPipOverlayState extends State { +class _AndroidPipOverlayState extends State + with CallParticipantsSortingMixin { + StreamSubscription?>? _participantsSubscription; + + @override + Filter? get participantFilter => null; + + @override + Sort? get participantSort => + widget.pictureInPictureConfiguration?.sort ?? widget.sort; + + @override + void initState() { + super.initState(); + recalculateParticipants(widget.call.state.value.callParticipants); + + _participantsSubscription = widget.call + .partialState((state) => state.callParticipants) + .listen(recalculateParticipants); + } + + @override + void dispose() { + _participantsSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { + Widget pipBody = const SizedBox.shrink(); + + final customBuilder = + widget + .pictureInPictureConfiguration + ?.androidPiPConfiguration + .callPictureInPictureWidgetBuilder ?? + widget.customBuilder; + + if (customBuilder == null && sortedParticipants.isNotEmpty) { + final pipParticipant = sortedParticipants.first; + + final hasScreenShare = + pipParticipant.isScreenShareEnabled && + pipParticipant.screenShareTrack != null; + + // Show screen share if: + // 1. prioritise is true and screen share is available, OR + // 2. video is disabled but screen share is available (fallback) + final shouldShowScreenShare = + hasScreenShare && + (widget.pictureInPictureConfiguration?.pipTrackPriority != + PipTrackPriority.camera || + !pipParticipant.isVideoEnabled); + + if (shouldShowScreenShare) { + pipBody = ScreenShareContent( + key: ValueKey( + '${pipParticipant.uniqueParticipantKey} - screenShareContent', + ), + call: widget.call, + participant: pipParticipant, + ); + } else { + pipBody = StreamCallParticipant( + // We use the sessionId as the key to map the state to the participant. + key: Key(pipParticipant.uniqueParticipantKey), + call: widget.call, + participant: pipParticipant, + ); + } + } + return Material( color: Colors.black, child: SizedBox.expand( - child: - widget.customBuilder?.call(context, widget.call) ?? - StreamCallParticipants( - call: widget.call, - layoutMode: ParticipantLayoutMode.pictureInPicture, - sort: widget.sort, - ), + child: customBuilder?.call(context, widget.call) ?? pipBody, ), ); } diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_configuration.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_configuration.dart index cd90085ac..f6fbc4fd6 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_configuration.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/picture_in_picture_configuration.dart @@ -9,11 +9,14 @@ typedef CallPictureInPictureBuilder = CallState callState, ); +enum PipTrackPriority { screenShare, camera } + /// Configuration for picture-in-picture mode. class PictureInPictureConfiguration { const PictureInPictureConfiguration({ this.enablePictureInPicture = false, this.disablePictureInPictureWhenScreenSharing = true, + this.pipTrackPriority = PipTrackPriority.screenShare, this.sort, this.androidPiPConfiguration = const AndroidPictureInPictureConfiguration(), this.iOSPiPConfiguration = const IOSPictureInPictureConfiguration(), @@ -25,6 +28,17 @@ class PictureInPictureConfiguration { /// Whether to disable picture-in-picture mode during screen sharing on the device. final bool disablePictureInPictureWhenScreenSharing; + /// Determines which video track to prioritise in the PiP view. + /// + /// If [PipTrackPriority.screenShare], the screen sharing track will be + /// displayed in the PiP view if available for the first participant + /// determined by the sorting function. + /// + /// If [PipTrackPriority.camera], the camera track will be preferred. + /// However, if the camera track is disabled and screen sharing is available, + /// the screen share will be shown as a fallback. + final PipTrackPriority pipTrackPriority; + /// Sorting function for participants in picture-in-picture mode. /// The first participant will be displayed in the PiP view. /// If not provided, the default sorting prioritising speaker / screen sharer will be used. diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_android_view.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_android_view.dart index 9e3b832a8..184198a08 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_android_view.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_android_view.dart @@ -146,11 +146,7 @@ class _StreamPictureInPictureAndroidViewState _overlayEntry = OverlayEntry( builder: (context) => AndroidPipOverlay( call: call, - sort: widget.configuration.sort, - customBuilder: widget - .configuration - .androidPiPConfiguration - .callPictureInPictureWidgetBuilder, + pictureInPictureConfiguration: widget.configuration, ), ); diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_ui_kit_view.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_ui_kit_view.dart index 8a2d1fb34..5e1d16ded 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_ui_kit_view.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/stream_picture_in_picture_ui_kit_view.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -39,13 +41,26 @@ class StreamPictureInPictureUiKitView extends StatefulWidget { const StreamPictureInPictureUiKitView({ super.key, required this.call, - required this.configuration, + @Deprecated( + 'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead', + ) + this.configuration, + this.pictureInPictureConfiguration, + @Deprecated( + 'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead', + ) this.participantSort, - }); + }) : assert( + configuration != null || pictureInPictureConfiguration != null, + 'Either configuration or pictureInPictureConfiguration must be provided', + ); final Call call; + final PictureInPictureConfiguration? pictureInPictureConfiguration; + @Deprecated('Use [pictureInPictureConfiguration.sort] instead') final Comparator? participantSort; - final IOSPictureInPictureConfiguration configuration; + @Deprecated('Use [pictureInPictureConfiguration.iOSPiPConfiguration] instead') + final IOSPictureInPictureConfiguration? configuration; @override State createState() => @@ -75,30 +90,47 @@ class _StreamPictureInPictureUiKitViewState final sorted = List.from(participants); mergeSort( sorted, - compare: widget.participantSort ?? CallParticipantSortingPresets.speaker, + compare: + widget.pictureInPictureConfiguration?.sort ?? + widget.participantSort ?? + CallParticipantSortingPresets.speaker, ); if (sorted.isNotEmpty) { - final participant = sorted.first; + final pipParticipant = sorted.first; + + final hasScreenShare = + pipParticipant.isScreenShareEnabled && + pipParticipant.screenShareTrack != null; + + // Show screen share if: + // 1. prioritise is true and screen share is available, OR + // 2. video is disabled but screen share is available (fallback) + final shouldShowScreenShare = + hasScreenShare && + (widget.pictureInPictureConfiguration?.pipTrackPriority != + PipTrackPriority.camera || + !pipParticipant.isVideoEnabled); + + final priorityTrack = shouldShowScreenShare + ? SfuTrackType.screenShare + : SfuTrackType.video; final videoTrack = widget.call.getTrack( - participant.trackIdPrefix, - participant.isScreenShareEnabled - ? SfuTrackType.screenShare - : SfuTrackType.video, + pipParticipant.trackIdPrefix, + priorityTrack, ); if (videoTrack == null && - (participant.isVideoEnabled || participant.isScreenShareEnabled)) { + (pipParticipant.isVideoEnabled || + pipParticipant.isScreenShareEnabled)) { // If the video track is not available, we need to update the subscription // to ensure that the participant's video is displayed correctly. await widget.call.updateSubscription( - userId: participant.userId, - sessionId: participant.sessionId, - trackIdPrefix: participant.trackIdPrefix, - trackType: participant.isScreenShareEnabled - ? SfuTrackType.screenShare - : SfuTrackType.video, + userId: pipParticipant.userId, + sessionId: pipParticipant.sessionId, + trackIdPrefix: pipParticipant.trackIdPrefix, + trackType: priorityTrack, videoDimension: RtcVideoDimensionPresets.h360_169, ); @@ -109,20 +141,33 @@ class _StreamPictureInPictureUiKitViewState 'updateParticipant', { 'trackId': videoTrack?.mediaTrack.id, - 'name': participant.name.isEmpty - ? participant.userId - : participant.name, - 'imageUrl': participant.image, - 'isAudioEnabled': participant.isAudioEnabled, + 'name': pipParticipant.name.isEmpty + ? pipParticipant.userId + : pipParticipant.name, + 'imageUrl': pipParticipant.image, + 'isAudioEnabled': pipParticipant.isAudioEnabled, 'isVideoEnabled': videoTrack != null && - (participant.isVideoEnabled || participant.isScreenShareEnabled), - 'connectionQuality': participant.connectionQuality.name, - 'showParticipantName': widget.configuration.showParticipantName, + (pipParticipant.isVideoEnabled || shouldShowScreenShare), + 'connectionQuality': pipParticipant.connectionQuality.name, + 'showParticipantName': + widget + .pictureInPictureConfiguration + ?.iOSPiPConfiguration + .showParticipantName ?? + widget.configuration?.showParticipantName, 'showMicrophoneIndicator': - widget.configuration.showMicrophoneIndicator, + widget + .pictureInPictureConfiguration + ?.iOSPiPConfiguration + .showMicrophoneIndicator ?? + widget.configuration?.showMicrophoneIndicator, 'showConnectionQualityIndicator': - widget.configuration.showConnectionQualityIndicator, + widget + .pictureInPictureConfiguration + ?.iOSPiPConfiguration + .showConnectionQualityIndicator ?? + widget.configuration?.showConnectionQualityIndicator, }, ); } @@ -143,7 +188,12 @@ class _StreamPictureInPictureUiKitViewState _handleParticipantsChange( state.callParticipants, - widget.configuration.includeLocalParticipantVideo && + (widget + .pictureInPictureConfiguration + ?.iOSPiPConfiguration + .includeLocalParticipantVideo ?? + widget.configuration?.includeLocalParticipantVideo ?? + true) && widget.call.state.value.iOSMultitaskingCameraAccessEnabled, ); }, @@ -228,6 +278,29 @@ class _StreamPictureInPictureUiKitViewState } } } + + for (final participant in participants.where( + (p) => p.isScreenShareEnabled, + )) { + if (!enable && participant.isSpeaking) { + // Do not disable video track of speaking participant + continue; + } + + final screenShareTrackState = + participant.publishedTracks[SfuTrackType.screenShare]; + if ((screenShareTrackState is RemoteTrackState && + screenShareTrackState.subscribed) || + participant.isLocal) { + final track = widget.call.getTrack( + participant.trackIdPrefix, + SfuTrackType.screenShare, + ); + if (track != null) { + track.mediaTrack.enabled = enable; + } + } + } } @override diff --git a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart index c63d9fd43..c724c12f4 100644 --- a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart +++ b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart @@ -179,11 +179,8 @@ class _LivestreamContentState extends State { width: 300, child: StreamPictureInPictureUiKitView( call: call, - configuration: widget - .pictureInPictureConfiguration - .iOSPiPConfiguration, - participantSort: - widget.pictureInPictureConfiguration.sort, + pictureInPictureConfiguration: + widget.pictureInPictureConfiguration, ), ), if (CurrentPlatform.isAndroid && pipEnabled) diff --git a/packages/stream_video_flutter/lib/stream_video_flutter.dart b/packages/stream_video_flutter/lib/stream_video_flutter.dart index 75d6300d1..0c0f5c8be 100644 --- a/packages/stream_video_flutter/lib/stream_video_flutter.dart +++ b/packages/stream_video_flutter/lib/stream_video_flutter.dart @@ -27,6 +27,7 @@ export 'src/call_controls/controls/toggle_screen_sharing_option.dart'; export 'src/call_controls/controls/toggle_speakerphone_option.dart'; export 'src/call_participants/call_participant.dart'; export 'src/call_participants/call_participants.dart'; +export 'src/call_participants/call_participants_sorting_mixin.dart'; export 'src/call_participants/layout/participant_layout_mode.dart'; export 'src/call_participants/local_video.dart'; export 'src/call_screen/call_container.dart';