Skip to content
Merged
2 changes: 1 addition & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ command:
device_info_plus: ^12.1.0
share_plus: ^11.0.0
stream_chat_flutter: ^9.17.0
stream_webrtc_flutter: ^2.2.3
stream_webrtc_flutter: ^2.2.4
stream_video: ^1.2.2
stream_video_flutter: ^1.2.2
stream_video_noise_cancellation: ^1.2.2
Expand Down
2 changes: 1 addition & 1 deletion packages/stream_video/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies:
rxdart: ^0.28.0
sdp_transform: ^0.3.2
state_notifier: ^1.0.0
stream_webrtc_flutter: ^2.2.3
stream_webrtc_flutter: ^2.2.4
synchronized: ^3.1.0
system_info2: ^4.0.0
tart: ^0.6.0
Expand Down
2 changes: 1 addition & 1 deletion packages/stream_video_filters/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dependencies:
sdk: flutter
plugin_platform_interface: ^2.0.2
stream_video: ^1.2.2
stream_webrtc_flutter: ^2.2.3
stream_webrtc_flutter: ^2.2.4

dev_dependencies:
flutter_lints: ^6.0.0
Expand Down
1 change: 1 addition & 0 deletions packages/stream_video_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* Added `reconnectReason` to reconnect details for sfu logs.
* Fixed race condition where automatic ICE restart could interfere with fast reconnect, causing subscriber video to not recover.
* [iOS] Fixed CallKit event suppression to avoid repeated mute toggle loops.
* [Android] Fixed video flickering in Skia renderer by scoping renderer keys with prefix and using stable participant-based keys.

## 1.2.2

Expand Down
2 changes: 1 addition & 1 deletion packages/stream_video_flutter/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies:
stream_video: ^1.2.2
stream_video_flutter: ^1.2.2
stream_video_push_notification: ^1.2.2
stream_webrtc_flutter: ^2.2.3
stream_webrtc_flutter: ^2.2.4

dependency_overrides:
stream_video:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class StreamCallParticipant extends StatelessWidget {
super.key,
required this.call,
required this.participant,
this.rendererScopePrefix,
this.videoFit,
this.backgroundColor,
this.borderRadius,
Expand Down Expand Up @@ -121,6 +122,9 @@ class StreamCallParticipant extends StatelessWidget {
/// Callback that is called when the size of the participant widget changes.
final ValueSetter<Size>? onSizeChanged;

/// Optional prefix to scope renderer keys (e.g. PiP vs main view).
final String? rendererScopePrefix;

@override
Widget build(BuildContext context) {
final theme = StreamCallParticipantTheme.of(context);
Expand Down Expand Up @@ -194,6 +198,10 @@ class StreamCallParticipant extends StatelessWidget {
return Stack(
children: [
StreamVideoRenderer(
key: ValueKey(
'${rendererScopePrefix ?? ''}${participant.uniqueParticipantKey}-video',
),
rendererScopePrefix: rendererScopePrefix,
call: call,
participant: participant,
videoTrackType: SfuTrackType.video,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ class StreamLivestreamHosts extends StatefulWidget {
CallParticipantState host,
) {
return StreamCallParticipant(
// We use the sessionId as the key to map the state to the participant.
key: Key(host.uniqueParticipantKey),
key: ValueKey(
'${host.uniqueParticipantKey}-livehost-video',
),
rendererScopePrefix: 'livehost',
call: call,
participant: host,
);
Expand Down Expand Up @@ -97,7 +99,7 @@ class _StreamLivestreamHostsState extends State<StreamLivestreamHosts>
Widget _buildScreenShareContent(CallParticipantState host) {
return widget.screenShareContentBuilder?.call(context, widget.call, host) ??
ScreenShareContent(
key: ValueKey('${host.uniqueParticipantKey} - screenShareContent'),
key: ValueKey('${host.uniqueParticipantKey}-livehost-screenshare'),
call: widget.call,
participant: host,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ class ScreenShareCallParticipantsContent extends StatelessWidget {
CallParticipantState participant,
) {
return StreamCallParticipant(
key: ValueKey(participant.uniqueParticipantKey),
key: ValueKey(
'${participant.uniqueParticipantKey}-screenshare-video',
),
rendererScopePrefix: 'screenshareContent',
call: call,
participant: participant,
);
Expand Down Expand Up @@ -74,8 +77,9 @@ class ScreenShareCallParticipantsContent extends StatelessWidget {
children: [
ScreenShareContent(
key: ValueKey(
'${participant.uniqueParticipantKey} - screenShareContent',
'${participant.uniqueParticipantKey}-screenShareContent',
),
rendererScopePrefix: 'screenshareContent',
call: call,
participant: participant,
),
Expand Down Expand Up @@ -118,6 +122,7 @@ class ScreenShareContent extends StatefulWidget {
super.key,
required this.call,
required this.participant,
this.rendererScopePrefix,
this.backgroundColor = const Color(0xFF272A30),
this.borderRadius = const BorderRadius.all(Radius.circular(8)),
});
Expand All @@ -128,6 +133,9 @@ class ScreenShareContent extends StatefulWidget {
/// The participant that shares their screen.
final CallParticipantState participant;

/// Optional prefix to scope renderer keys (e.g. PiP vs main view).
final String? rendererScopePrefix;

/// The background color for the video.
final Color backgroundColor;

Expand Down Expand Up @@ -171,6 +179,10 @@ class _ScreenShareContentState extends State<ScreenShareContent> {
}
},
child: StreamVideoRenderer(
key: ValueKey(
'${widget.rendererScopePrefix ?? ''}${widget.participant.uniqueParticipantKey}-screen-share',
),
rendererScopePrefix: widget.rendererScopePrefix,
call: widget.call,
participant: widget.participant,
videoFit: VideoFit.contain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ class _StreamCallContentState extends State<StreamCallContent> {
late bool _isScreenShareEnabled;
late CallStatus _status;

final _androidPipManager = AndroidPipManager.instance();
late bool _isInPictureInPictureMode =
_androidPipManager.isInPictureInPictureMode;

StreamSubscription<({CallStatus status, bool isScreenShareEnabled})>?
_callStateSubscription;

Expand All @@ -115,6 +119,10 @@ class _StreamCallContentState extends State<StreamCallContent> {
void initState() {
super.initState();
_startListeningToCallState();

_androidPipManager.addOnPictureInPictureModeChangedListener(
_handlePictureInPictureModeChanged,
);
}

@override
Expand All @@ -130,9 +138,18 @@ class _StreamCallContentState extends State<StreamCallContent> {
@override
void dispose() {
_callStateSubscription?.cancel();
_androidPipManager.removeOnPictureInPictureModeChangedListener(
_handlePictureInPictureModeChanged,
);
super.dispose();
}

void _handlePictureInPictureModeChanged(bool isInPictureInPictureMode) {
setState(() {
_isInPictureInPictureMode = isInPictureInPictureMode;
});
}

void _startListeningToCallState() {
final callState = call.state.value;
_status = callState.status;
Expand Down Expand Up @@ -190,11 +207,12 @@ class _StreamCallContentState extends State<StreamCallContent> {
call: call,
configuration: widget.pictureInPictureConfiguration,
),
widget.callParticipantsWidgetBuilder?.call(context, call) ??
StreamCallParticipants(
call: call,
layoutMode: widget.layoutMode,
),
if (!_isInPictureInPictureMode)
widget.callParticipantsWidgetBuilder?.call(context, call) ??
StreamCallParticipants(
call: call,
layoutMode: widget.layoutMode,
),
],
);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'package:flutter/services.dart';

import '../../../../stream_video_flutter.dart';

class AndroidPipManager {
factory AndroidPipManager.instance() {
return _instance ??= AndroidPipManager._();
}

AndroidPipManager._() {
_methodChannel.setMethodCallHandler(_handleMethodCall);
}

static const String _pipChannel = 'stream_video_flutter_android_pip';
static const MethodChannel _methodChannel = MethodChannel(_pipChannel);

static AndroidPipManager? _instance;

final _logger = taggedLogger(tag: 'SV:AndroidPipManager');

final List<ValueChanged<bool>> _onPictureInPictureModeChangedListeners = [];

bool _isInPictureInPictureMode = false;
bool get isInPictureInPictureMode => _isInPictureInPictureMode;

void addOnPictureInPictureModeChangedListener(ValueChanged<bool> listener) {
_onPictureInPictureModeChangedListeners.add(listener);
}

void removeOnPictureInPictureModeChangedListener(
ValueChanged<bool> listener,
) {
_onPictureInPictureModeChangedListeners.remove(listener);
}

/// Updates the Android side about whether PiP should be allowed based on current call state
Future<void> setPictureInPictureAllowed(bool isAllowed) async {
if (!CurrentPlatform.isAndroid) return;

try {
await _methodChannel.invokeMethod(
'setPictureInPictureAllowed',
isAllowed,
);
} catch (e) {
_logger.e(
() =>
'[_setPictureInPictureAllowed] Failed to set picture in picture allowed: $e',
);
}
}

Future<void> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'onPictureInPictureModeChanged':
final isInPictureInPictureMode = call.arguments as bool;
_isInPictureInPictureMode = isInPictureInPictureMode;

for (final listener in _onPictureInPictureModeChangedListeners) {
listener(isInPictureInPictureMode);
}

break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,18 @@ class _AndroidPipOverlayState extends State<AndroidPipOverlay>
if (shouldShowScreenShare) {
pipBody = ScreenShareContent(
key: ValueKey(
'${pipParticipant.uniqueParticipantKey} - screenShareContent',
'${pipParticipant.uniqueParticipantKey} - pipScreenShare',
),
rendererScopePrefix: 'pipScreenShare',
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),
key: ValueKey(
'${pipParticipant.uniqueParticipantKey} - pipVideo',
),
rendererScopePrefix: 'pipVideo',
call: widget.call,
participant: pipParticipant,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../../../../stream_video_flutter.dart';

Expand All @@ -25,13 +24,9 @@ class StreamPictureInPictureAndroidView extends StatefulWidget {

class _StreamPictureInPictureAndroidViewState
extends State<StreamPictureInPictureAndroidView> {
static const String _pipChannel = 'stream_video_flutter_android_pip';
static const MethodChannel _methodChannel = MethodChannel(_pipChannel);

final _logger = taggedLogger(tag: 'SV:StreamPictureInPictureAndroidView');

OverlayEntry? _overlayEntry;
bool _isOverlayVisible = false;
final AndroidPipManager _androidPipManager = AndroidPipManager.instance();

StreamSubscription<(CallStatus, bool?)>? _callStateSubscription;

Expand Down Expand Up @@ -102,31 +97,14 @@ class _StreamPictureInPictureAndroidViewState
}

/// Updates the Android side about whether PiP should be allowed based on current call state
Future<void> _setPictureInPictureAllowed(bool isAllowed) async {
try {
await _methodChannel.invokeMethod(
'setPictureInPictureAllowed',
isAllowed,
);
} catch (e) {
_logger.e(
() =>
'[_setPictureInPictureAllowed] Failed to set picture in picture allowed: $e',
);
}
Future<void> _setPictureInPictureAllowed(bool isAllowed) {
return _androidPipManager.setPictureInPictureAllowed(isAllowed);
}

void _setupPictureInPictureListener() {
_methodChannel.setMethodCallHandler(_handleMethodCall);
}

Future<void> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'onPictureInPictureModeChanged':
final isInPictureInPictureMode = call.arguments as bool;
_handlePictureInPictureModeChanged(isInPictureInPictureMode);
break;
}
_androidPipManager.addOnPictureInPictureModeChangedListener(
_handlePictureInPictureModeChanged,
);
}

void _handlePictureInPictureModeChanged(bool isInPictureInPictureMode) {
Expand All @@ -143,7 +121,9 @@ class _StreamPictureInPictureAndroidViewState
}

void _cleanupPictureInPictureListener() {
_methodChannel.setMethodCallHandler(null);
_androidPipManager.removeOnPictureInPictureModeChangedListener(
_handlePictureInPictureModeChanged,
);
}

/// Shows the fullscreen PiP overlay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,15 @@ class _LivestreamContentState extends State<LivestreamContent> {

CallParticipantBuilder get _defaultParticipantBuilder =>
(context, call, participant) => StreamCallParticipant(
key: ValueKey(
'${participant.uniqueParticipantKey}-livecontent-video',
),
rendererScopePrefix: 'livecontent',
call: call,
participant: participant,
backgroundColor: StreamVideoTheme.of(
context,
).colorTheme.livestreamBackground,
key: ValueKey(participant.uniqueParticipantKey),
showConnectionQualityIndicator: false,
showParticipantLabel: false,
showSpeakerBorder: false,
Expand All @@ -232,6 +235,10 @@ class _LivestreamContentState extends State<LivestreamContent> {
callParticipantBuilder: _defaultParticipantBuilder,
screenShareContentBuilder: (context, call, participant) =>
ScreenShareContent(
key: ValueKey(
'${participant.uniqueParticipantKey}-livecontent-screenshare',
),
rendererScopePrefix: 'livecontent',
call: call,
participant: participant,
backgroundColor: StreamVideoTheme.of(
Expand Down
Loading
Loading