diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index f7f5b6611..294a80e8d 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -71,6 +71,7 @@ - A coordinator WebSocket error frame that says nothing about the credentials — a rate limit, or an error about a single request — no longer closes an otherwise healthy connection. Only an expired or rejected token, or a rejected API key, closes the socket now; the rest are logged. - Fixed the participant sort reordering tiles that are visible on screen. One participant whose tile was not visible was enough to move the dominant speaker to the first tile. - A viewport visibility is now recorded whether or not the session accepts it. A dropped report left a participant recorded as something they were not for the rest of the call, since a viewport only ever reports what changed. +- On iOS devices without multitasking camera access, the camera track is now muted while the app is in the background, so other participants see camera-off instead of a frozen frame. ## 1.6.0 diff --git a/packages/stream_video/lib/src/internal/_background_mute_policy.dart b/packages/stream_video/lib/src/internal/_background_mute_policy.dart new file mode 100644 index 000000000..ea19d7df4 --- /dev/null +++ b/packages/stream_video/lib/src/internal/_background_mute_policy.dart @@ -0,0 +1,22 @@ +import 'package:stream_core/stream_core.dart'; + +/// Whether the camera track should be muted while the app is in the background. +/// +/// iOS suspends camera capture in the background unless the capture session +/// supports multitasking camera access, which leaves the other participants +/// looking at a frozen frame. Muting the track shows them camera-off instead. +/// +/// [multitaskingCameraAccessSupported] is `null` when it could not be read, in +/// which case the track is muted rather than risking the frozen frame. +bool shouldMuteCameraInBackground({ + required bool isVideoEnabled, + required bool muteVideoWhenInBackground, + required bool? multitaskingCameraAccessSupported, + required PlatformType platform, +}) { + if (!isVideoEnabled) return false; + if (muteVideoWhenInBackground) return true; + if (platform != PlatformType.ios) return false; + + return multitaskingCameraAccessSupported != true; +} diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index d98a81fcc..39be495a4 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -25,6 +25,7 @@ import 'core/connection_state.dart'; import 'core/internet_connection_network_state_provider.dart'; import 'errors/stream_video_exception.dart'; import 'errors/stream_video_exception_composer.dart'; +import 'internal/_background_mute_policy.dart'; import 'internal/_instance_holder.dart'; import 'latency/latency_service.dart'; import 'latency/latency_settings.dart'; @@ -593,6 +594,19 @@ class StreamVideo extends Disposable { ); } + /// Whether the capture session in use supports camera access while + /// multitasking, or `null` when it could not be read. + Future _multitaskingCameraAccessSupported() async { + if (!CurrentPlatform.isIos) return null; + + try { + return await rtc.Helper.isIOSMultitaskingCameraAccessSupported(); + } catch (e) { + _logger.w(() => '[multitaskingCameraAccessSupported] failed: $e'); + return null; + } + } + Future _onAppState(LifecycleState state) async { _logger.d(() => '[onAppState] state: $state'); try { @@ -611,6 +625,9 @@ class StreamVideo extends Disposable { _subscriptions.cancel(_idEvents); await _client.closeConnection(); } else if (activeCalls.isNotEmpty) { + final multitaskingCameraAccessSupported = + await _multitaskingCameraAccessSupported(); + for (final activeCall in activeCalls) { final callState = activeCall.state.value; final isVideoEnabled = @@ -618,7 +635,13 @@ class StreamVideo extends Disposable { final isAudioEnabled = callState.localParticipant?.isAudioEnabled ?? false; - if (_options.muteVideoWhenInBackground && isVideoEnabled) { + if (shouldMuteCameraInBackground( + isVideoEnabled: isVideoEnabled, + muteVideoWhenInBackground: _options.muteVideoWhenInBackground, + multitaskingCameraAccessSupported: + multitaskingCameraAccessSupported, + platform: CurrentPlatform.type, + )) { await activeCall.setCameraEnabled(enabled: false); _mutedCameraByStateChange[activeCall.callCid.value] = true; _logger.v(() => 'Muted camera track since app was paused.'); @@ -1611,6 +1634,10 @@ class StreamVideoOptions { final AudioProcessor? audioProcessor; + /// Mutes the camera track while the app is in the background. + /// + /// On iOS devices without multitasking camera access the camera track is + /// muted in the background regardless of this option. final bool muteVideoWhenInBackground; final bool muteAudioWhenInBackground; final bool autoConnect; diff --git a/packages/stream_video/test/src/internal/background_mute_policy_test.dart b/packages/stream_video/test/src/internal/background_mute_policy_test.dart new file mode 100644 index 000000000..78d0f3794 --- /dev/null +++ b/packages/stream_video/test/src/internal/background_mute_policy_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:stream_video/src/internal/_background_mute_policy.dart'; + +void main() { + group('shouldMuteCameraInBackground', () { + bool shouldMute({ + bool isVideoEnabled = true, + bool muteVideoWhenInBackground = false, + bool? multitaskingCameraAccessSupported = false, + PlatformType platform = PlatformType.ios, + }) { + return shouldMuteCameraInBackground( + isVideoEnabled: isVideoEnabled, + muteVideoWhenInBackground: muteVideoWhenInBackground, + multitaskingCameraAccessSupported: multitaskingCameraAccessSupported, + platform: platform, + ); + } + + test('does not mute a camera that is already off', () { + expect(shouldMute(isVideoEnabled: false), isFalse); + }); + + test('mutes on iOS without multitasking camera access', () { + expect(shouldMute(), isTrue); + }); + + test('does not mute on iOS with multitasking camera access', () { + expect(shouldMute(multitaskingCameraAccessSupported: true), isFalse); + }); + + test('mutes on iOS when support could not be read', () { + expect(shouldMute(multitaskingCameraAccessSupported: null), isTrue); + }); + + test('does not mute on other platforms', () { + for (final platform in [ + PlatformType.android, + PlatformType.web, + PlatformType.macOS, + PlatformType.windows, + PlatformType.linux, + ]) { + expect( + shouldMute(platform: platform), + isFalse, + reason: 'should not mute on $platform', + ); + } + }); + + test('mutes on any platform when the option is set', () { + expect( + shouldMute( + muteVideoWhenInBackground: true, + platform: PlatformType.android, + ), + isTrue, + ); + expect( + shouldMute( + muteVideoWhenInBackground: true, + multitaskingCameraAccessSupported: true, + ), + isTrue, + ); + }); + + test('the option does not mute a camera that is off', () { + expect( + shouldMute(isVideoEnabled: false, muteVideoWhenInBackground: true), + isFalse, + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index ae4f729a2..24669168d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1989,12 +1989,13 @@ packages: source: hosted version: "2.1.1" stream_webrtc_flutter: - dependency: transitive + dependency: "direct overridden" description: - name: stream_webrtc_flutter - sha256: "6938c87d6054e7ddcd5114641f7173e84f000f14aedfc57651c1115726dcf356" - url: "https://pub.dev" - source: hosted + path: "." + ref: "3bb87874d59bb9276969daef356f5fefa01639c5" + resolved-ref: "3bb87874d59bb9276969daef356f5fefa01639c5" + url: "https://github.com/GetStream/webrtc-flutter.git" + source: git version: "3.2.0" string_scanner: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index a47445b60..9949059f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,12 @@ dev_dependencies: dependency_overrides: cli_util: ^0.5.1 file_picker: ^12.0.0-beta.5 + # TODO: drop once a release with the multitasking camera access fix is out. + # https://github.com/GetStream/webrtc-flutter/pull/88 + stream_webrtc_flutter: + git: + url: https://github.com/GetStream/webrtc-flutter.git + ref: 3bb87874d59bb9276969daef356f5fefa01639c5 stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git