From f858faa3ed57602fa33b2e5e4690974ca2a3f92e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:22:32 +0200 Subject: [PATCH 1/2] fix(llc): mute camera in background on devices without multitasking camera access iOS suspends camera capture in the background unless the device supports multitasking camera access, leaving other participants with a frozen frame. On those devices the camera track is now muted while the app is backgrounded and restored on resume. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 1 + .../src/internal/_background_mute_policy.dart | 18 +++++ .../stream_video/lib/src/stream_video.dart | 13 +++- .../internal/background_mute_policy_test.dart | 73 +++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/stream_video/lib/src/internal/_background_mute_policy.dart create mode 100644 packages/stream_video/test/src/internal/background_mute_policy_test.dart 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..33f569290 --- /dev/null +++ b/packages/stream_video/lib/src/internal/_background_mute_policy.dart @@ -0,0 +1,18 @@ +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 device supports +/// multitasking camera access, which leaves the other participants looking at a +/// frozen frame. Muting the track shows them camera-off instead. +bool shouldMuteCameraInBackground({ + required bool isVideoEnabled, + required bool muteVideoWhenInBackground, + required bool multitaskingCameraAccessEnabled, + required PlatformType platform, +}) { + if (!isVideoEnabled) return false; + if (muteVideoWhenInBackground) return true; + + return platform == PlatformType.ios && !multitaskingCameraAccessEnabled; +} diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index d98a81fcc..1525a75d6 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'; @@ -618,7 +619,13 @@ class StreamVideo extends Disposable { final isAudioEnabled = callState.localParticipant?.isAudioEnabled ?? false; - if (_options.muteVideoWhenInBackground && isVideoEnabled) { + if (shouldMuteCameraInBackground( + isVideoEnabled: isVideoEnabled, + muteVideoWhenInBackground: _options.muteVideoWhenInBackground, + multitaskingCameraAccessEnabled: + callState.iOSMultitaskingCameraAccessEnabled, + platform: CurrentPlatform.type, + )) { await activeCall.setCameraEnabled(enabled: false); _mutedCameraByStateChange[activeCall.callCid.value] = true; _logger.v(() => 'Muted camera track since app was paused.'); @@ -1611,6 +1618,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..a29c7ed52 --- /dev/null +++ b/packages/stream_video/test/src/internal/background_mute_policy_test.dart @@ -0,0 +1,73 @@ +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 multitaskingCameraAccessEnabled = false, + PlatformType platform = PlatformType.ios, + }) { + return shouldMuteCameraInBackground( + isVideoEnabled: isVideoEnabled, + muteVideoWhenInBackground: muteVideoWhenInBackground, + multitaskingCameraAccessEnabled: multitaskingCameraAccessEnabled, + 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(multitaskingCameraAccessEnabled: true), isFalse); + }); + + 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, + multitaskingCameraAccessEnabled: true, + ), + isTrue, + ); + }); + + test('the option does not mute a camera that is off', () { + expect( + shouldMute(isVideoEnabled: false, muteVideoWhenInBackground: true), + isFalse, + ); + }); + }); +} From e2f1e74a1b3b4c952727d1e6037ede55b480e506 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:46:27 +0200 Subject: [PATCH 2/2] fix(llc): decide the background mute from device support, not cached state The mute decision now reads isIOSMultitaskingCameraAccessSupported from the capture session in use instead of inferring it from whether the SDK managed to turn multitasking camera access on, which also reads false when it was never asked for. Requires the plugin fix in GetStream/webrtc-flutter#88, pinned here through a git override until it ships. Co-Authored-By: Claude Opus 5 --- .../src/internal/_background_mute_policy.dart | 14 ++++++++----- .../stream_video/lib/src/stream_video.dart | 20 +++++++++++++++++-- .../internal/background_mute_policy_test.dart | 12 +++++++---- pubspec.lock | 11 +++++----- pubspec.yaml | 6 ++++++ 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/stream_video/lib/src/internal/_background_mute_policy.dart b/packages/stream_video/lib/src/internal/_background_mute_policy.dart index 33f569290..ea19d7df4 100644 --- a/packages/stream_video/lib/src/internal/_background_mute_policy.dart +++ b/packages/stream_video/lib/src/internal/_background_mute_policy.dart @@ -2,17 +2,21 @@ 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 device supports -/// multitasking camera access, which leaves the other participants looking at a -/// frozen frame. Muting the track shows them camera-off instead. +/// 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 multitaskingCameraAccessEnabled, + required bool? multitaskingCameraAccessSupported, required PlatformType platform, }) { if (!isVideoEnabled) return false; if (muteVideoWhenInBackground) return true; + if (platform != PlatformType.ios) return false; - return platform == PlatformType.ios && !multitaskingCameraAccessEnabled; + return multitaskingCameraAccessSupported != true; } diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index 1525a75d6..39be495a4 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -594,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 { @@ -612,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 = @@ -622,8 +638,8 @@ class StreamVideo extends Disposable { if (shouldMuteCameraInBackground( isVideoEnabled: isVideoEnabled, muteVideoWhenInBackground: _options.muteVideoWhenInBackground, - multitaskingCameraAccessEnabled: - callState.iOSMultitaskingCameraAccessEnabled, + multitaskingCameraAccessSupported: + multitaskingCameraAccessSupported, platform: CurrentPlatform.type, )) { await activeCall.setCameraEnabled(enabled: false); 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 index a29c7ed52..78d0f3794 100644 --- a/packages/stream_video/test/src/internal/background_mute_policy_test.dart +++ b/packages/stream_video/test/src/internal/background_mute_policy_test.dart @@ -7,13 +7,13 @@ void main() { bool shouldMute({ bool isVideoEnabled = true, bool muteVideoWhenInBackground = false, - bool multitaskingCameraAccessEnabled = false, + bool? multitaskingCameraAccessSupported = false, PlatformType platform = PlatformType.ios, }) { return shouldMuteCameraInBackground( isVideoEnabled: isVideoEnabled, muteVideoWhenInBackground: muteVideoWhenInBackground, - multitaskingCameraAccessEnabled: multitaskingCameraAccessEnabled, + multitaskingCameraAccessSupported: multitaskingCameraAccessSupported, platform: platform, ); } @@ -27,7 +27,11 @@ void main() { }); test('does not mute on iOS with multitasking camera access', () { - expect(shouldMute(multitaskingCameraAccessEnabled: true), isFalse); + 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', () { @@ -57,7 +61,7 @@ void main() { expect( shouldMute( muteVideoWhenInBackground: true, - multitaskingCameraAccessEnabled: true, + multitaskingCameraAccessSupported: true, ), isTrue, ); 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