Skip to content

Commit eab42dd

Browse files
authored
feat(llc): video moderation (#1178)
* Regenerated open API models * fix * fix * video moderation blur * tweaks * blur fix
1 parent e6d9a76 commit eab42dd

18 files changed

Lines changed: 350 additions & 6 deletions

File tree

packages/stream_video/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
### 🐞 Fixed
44
* Added handling for SFU `iceRestart` event — the client now correctly performs ICE restart and renegotiation when instructed by the SFU, improving fast reconnect reliability.
55
* Added PeerConnection SDP rollback on failed remote answer to prevent the publisher from getting stuck in an inconsistent signaling state.
6+
67
### ✅ Added
8+
* Added video moderation support by providing `VideoModerationConfig` in `CallPreferences`. Check [cookbook](https://getstream.io/video/docs/flutter/ui-cookbook/call-moderation/) for more details.
79
* Added HiFi audio mode for high-fidelity scenarios such as live music, podcasts, and professional streaming. Use `SfuAudioBitrateProfile` to select an audio quality profile before joining a call:
810
* `SfuAudioBitrateProfile.voiceStandard` – Standard voice (64 kbps, default)
911
* `SfuAudioBitrateProfile.voiceHighQuality` – High-quality voice (128 kbps)

packages/stream_video/lib/src/call/call.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ class Call {
297297

298298
final Map<String, Timer> _reactionTimers = {};
299299
final Map<String, Timer> _captionsTimers = {};
300+
Timer? _videoModerationTimer;
301+
void Function()? _onModerationBlurApply;
302+
void Function()? _onModerationBlurClear;
300303
final List<CancelableOperation<void>> _sfuStatsTimers = [];
301304
final Set<SfuClientCapability> _sfuClientCapabilities = {
302305
SfuClientCapability.subscriberVideoPause, // on by default
@@ -624,11 +627,79 @@ class Call {
624627
event.metadata,
625628
updateMembers: false,
626629
);
630+
case StreamCallModerationWarningEvent _:
631+
return _handleModerationWarningEvent(event);
632+
case StreamCallModerationBlurEvent _:
633+
return _handleModerationBlurEvent(event);
627634
default:
628635
break;
629636
}
630637
}
631638

639+
void _handleModerationWarningEvent(
640+
StreamCallModerationWarningEvent event,
641+
) {
642+
final config = state.value.preferences.videoModerationConfig;
643+
if (config.isDisabled || event.userId != _streamVideo.currentUser.id) {
644+
return;
645+
}
646+
647+
config.onWarning?.call(event.message);
648+
}
649+
650+
Future<void> _handleModerationBlurEvent(
651+
StreamCallModerationBlurEvent event,
652+
) async {
653+
final config = state.value.preferences.videoModerationConfig;
654+
if (config.isDisabled || event.userId != _streamVideo.currentUser.id) {
655+
return;
656+
}
657+
658+
_stateManager.coordinatorCallModerationBlur(event.userId);
659+
660+
_videoModerationTimer?.cancel();
661+
_videoModerationTimer = null;
662+
if (config.duration != null) {
663+
_videoModerationTimer = Timer(config.duration!, clearModerationBlur);
664+
}
665+
666+
if (config.muteAudio) await setMicrophoneEnabled(enabled: false);
667+
if (config.muteVideo) await setCameraEnabled(enabled: false);
668+
if (config.applyBlur) _onModerationBlurApply?.call();
669+
config.onApply?.call();
670+
}
671+
672+
/// Clears the moderation action, restoring normal operation.
673+
///
674+
/// When [VideoModerationConfig.muteAudio] / [VideoModerationConfig.muteVideo]
675+
/// were active, re-enabling mic/camera is allowed again but they stay off
676+
/// until the user manually re-enables them.
677+
void clearModerationBlur() {
678+
_videoModerationTimer?.cancel();
679+
_videoModerationTimer = null;
680+
681+
if (!state.value.isVideoModerated) return;
682+
683+
final config = state.value.preferences.videoModerationConfig;
684+
_stateManager.clearModerationBlur();
685+
686+
if (config.applyBlur) _onModerationBlurClear?.call();
687+
config.onClear?.call();
688+
}
689+
690+
/// Registers handlers for the native blur effect pipeline.
691+
///
692+
/// Called automatically by `StreamVideoEffectsManager` from
693+
/// `stream_video_filters` when [VideoModerationConfig.applyBlur] is true.
694+
@internal
695+
void setModerationBlurEffectHandlers({
696+
required void Function() onApply,
697+
required void Function() onClear,
698+
}) {
699+
_onModerationBlurApply = onApply;
700+
_onModerationBlurClear = onClear;
701+
}
702+
632703
@internal
633704
void traceSessionLog(String tag, dynamic data) {
634705
_session?.trace(tag, data);
@@ -1852,6 +1923,8 @@ class Call {
18521923
]) {
18531924
timer.cancel();
18541925
}
1926+
_videoModerationTimer?.cancel();
1927+
_videoModerationTimer = null;
18551928

18561929
for (final operation in _sfuStatsTimers) {
18571930
await operation.cancel();
@@ -2932,6 +3005,12 @@ class Call {
29323005
required bool enabled,
29333006
CameraConstraints? constraints,
29343007
}) async {
3008+
if (enabled &&
3009+
state.value.isVideoModerated &&
3010+
state.value.preferences.videoModerationConfig.muteVideo) {
3011+
_logger.w(() => '[setCameraEnabled] blocked by video moderation');
3012+
return Result.error('Blocked by video moderation');
3013+
}
29353014
if (enabled && !hasPermission(CallPermission.sendVideo)) {
29363015
return Result.error('Missing permission to send video');
29373016
}
@@ -3008,6 +3087,12 @@ class Call {
30083087
required bool enabled,
30093088
AudioConstraints? constraints,
30103089
}) async {
3090+
if (enabled &&
3091+
state.value.isVideoModerated &&
3092+
state.value.preferences.videoModerationConfig.muteAudio) {
3093+
_logger.w(() => '[setMicrophoneEnabled] blocked by video moderation');
3094+
return Result.error('Blocked by video moderation');
3095+
}
30113096
if (enabled && !hasPermission(CallPermission.sendAudio)) {
30123097
return Result.error('Missing permission to send audio');
30133098
}

packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,26 @@ mixin StateCoordinatorMixin on StateNotifier<CallState> {
518518
.toList(),
519519
);
520520
}
521+
522+
void coordinatorCallModerationBlur(
523+
String userId,
524+
) {
525+
if (userId != state.currentUserId) {
526+
_logger.i(
527+
() => '[coordinatorCallModeration] rejected (not current user)',
528+
);
529+
return;
530+
}
531+
532+
state = state.copyWith(
533+
isVideoModerated: true,
534+
);
535+
}
536+
537+
void clearModerationBlur() {
538+
_logger.i(() => '[clearModerationBlur]');
539+
state = state.copyWith(
540+
isVideoModerated: false,
541+
);
542+
}
521543
}

packages/stream_video/lib/src/call_state.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class CallState extends Equatable {
5454
anonymousParticipantCount: 0,
5555
iOSMultitaskingCameraAccessEnabled: false,
5656
custom: const {},
57+
isVideoModerated: false,
5758
);
5859
}
5960

@@ -95,6 +96,7 @@ class CallState extends Equatable {
9596
required this.anonymousParticipantCount,
9697
required this.iOSMultitaskingCameraAccessEnabled,
9798
required this.custom,
99+
required this.isVideoModerated,
98100
});
99101

100102
final CallPreferences preferences;
@@ -135,6 +137,9 @@ class CallState extends Equatable {
135137
final bool iOSMultitaskingCameraAccessEnabled;
136138
final Map<String, Object> custom;
137139

140+
/// Whether the local user's video is currently blurred by moderation.
141+
final bool isVideoModerated;
142+
138143
String get callId => callCid.id;
139144

140145
StreamCallType get callType => callCid.type;
@@ -206,6 +211,7 @@ class CallState extends Equatable {
206211
int? anonymousParticipantCount,
207212
bool? iOSMultitaskingCameraAccessEnabled,
208213
Map<String, Object>? custom,
214+
bool? isVideoModerated,
209215
}) {
210216
return CallState._(
211217
preferences: preferences ?? this.preferences,
@@ -248,6 +254,7 @@ class CallState extends Equatable {
248254
iOSMultitaskingCameraAccessEnabled ??
249255
this.iOSMultitaskingCameraAccessEnabled,
250256
custom: custom ?? this.custom,
257+
isVideoModerated: isVideoModerated ?? this.isVideoModerated,
251258
);
252259
}
253260

@@ -321,13 +328,15 @@ class CallState extends Equatable {
321328
anonymousParticipantCount,
322329
iOSMultitaskingCameraAccessEnabled,
323330
custom,
331+
isVideoModerated,
324332
];
325333

326334
@override
327335
String toString() {
328336
return 'CallState(status: $status, currentUserId: $currentUserId,'
329337
' callCid: $callCid, createdByUser: $createdByUser,'
330338
' sessionId: $sessionId, isRecording: $isRecording,'
339+
' isVideoModerated: $isVideoModerated,'
331340
' settings: $settings, egress: $egress, '
332341
' videoInputDevice: $videoInputDevice,'
333342
' audioInputDevice: $audioInputDevice,'

packages/stream_video/lib/src/models/call_preferences.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'call_client_publish_options.dart';
2+
import 'moderation_blur_config.dart';
23

34
abstract class CallPreferences {
45
/// The maximum duration to wait when establishing a connection to the call.
@@ -49,6 +50,10 @@ abstract class CallPreferences {
4950
/// The maximum number of closed caption lines that can be visible
5051
/// simultaneously on screen.
5152
int get closedCaptionsVisibleCaptions;
53+
54+
/// Configuration for how the SDK handles call moderation events.
55+
/// Defaults to [VideoModerationConfig.disabled].
56+
VideoModerationConfig get videoModerationConfig;
5257
}
5358

5459
class DefaultCallPreferences implements CallPreferences {
@@ -62,6 +67,7 @@ class DefaultCallPreferences implements CallPreferences {
6267
this.clientPublishOptions,
6368
this.closedCaptionsVisibilityDurationMs = 2700,
6469
this.closedCaptionsVisibleCaptions = 2,
70+
this.videoModerationConfig = const VideoModerationConfig.disabled(),
6571
});
6672

6773
/// The maximum duration to wait when establishing a connection to the call.
@@ -137,4 +143,10 @@ class DefaultCallPreferences implements CallPreferences {
137143
/// Defaults to 2 lines.
138144
@override
139145
final int closedCaptionsVisibleCaptions;
146+
147+
/// Configuration for how the SDK handles call moderation events.
148+
///
149+
/// Defaults to [VideoModerationConfig.disabled].
150+
@override
151+
final VideoModerationConfig videoModerationConfig;
140152
}

packages/stream_video/lib/src/models/models.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export 'call_status.dart';
1919
export 'call_track_state.dart';
2020
export 'disconnect_reason.dart';
2121
export 'guest_created_data.dart';
22+
export 'moderation_blur_config.dart';
2223
export 'push_device.dart';
2324
export 'push_provider.dart';
2425
export 'queried_calls.dart';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'dart:ui';
2+
3+
/// Configures how the SDK handles `call.moderation` events.
4+
///
5+
/// Convenience constructors [VideoModerationConfig.disabled],
6+
/// [VideoModerationConfig.mute], and [VideoModerationConfig.blur] cover
7+
/// the most common presets.
8+
class VideoModerationConfig {
9+
const VideoModerationConfig({
10+
this.muteAudio = false,
11+
this.muteVideo = false,
12+
this.applyBlur = false,
13+
this.duration,
14+
this.onApply,
15+
this.onWarning,
16+
this.onClear,
17+
});
18+
19+
/// No automatic action. The event is still emitted on the call's event
20+
/// stream for manual handling via `call.callEvents`.
21+
const VideoModerationConfig.disabled()
22+
: muteAudio = false,
23+
muteVideo = false,
24+
applyBlur = false,
25+
duration = null,
26+
onApply = null,
27+
onWarning = null,
28+
onClear = null;
29+
30+
/// Mutes the local user's microphone and camera, and prevents re-enabling
31+
/// them for the configured [duration]. If [duration] is null, the mute
32+
/// persists until `call.clearModerationBlur()` is called.
33+
const VideoModerationConfig.mute({this.duration})
34+
: muteAudio = true,
35+
muteVideo = true,
36+
applyBlur = false,
37+
onApply = null,
38+
onWarning = null,
39+
onClear = null;
40+
41+
/// Applies a full-frame native blur on the camera track via
42+
/// `StreamVideoEffectsManager` from the `stream_video_filters` package.
43+
/// The blur is visible to ALL participants because it modifies frames
44+
/// before encoding. Requires the `stream_video_filters` package.
45+
const VideoModerationConfig.blur({this.duration})
46+
: muteAudio = false,
47+
muteVideo = false,
48+
applyBlur = true,
49+
onApply = null,
50+
onWarning = null,
51+
onClear = null;
52+
53+
final bool muteAudio;
54+
final bool muteVideo;
55+
final bool applyBlur;
56+
57+
final Duration? duration;
58+
59+
final VoidCallback? onApply;
60+
final void Function(String)? onWarning;
61+
final VoidCallback? onClear;
62+
63+
/// Whether this config takes no automatic action.
64+
bool get isDisabled =>
65+
!muteAudio &&
66+
!muteVideo &&
67+
!applyBlur &&
68+
onApply == null &&
69+
onWarning == null &&
70+
onClear == null;
71+
}

packages/stream_video/lib/src/retry/retry_manager.dart

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import '../../open_api/video/coordinator/api.dart';
12
import '../errors/video_error.dart';
23
import '../utils/result.dart';
34
import 'retry_policy.dart';
@@ -30,8 +31,30 @@ class RpcRetryManager {
3031
delegate,
3132
);
3233
retryAttempt++;
33-
} while (result.isFailure && retryAttempt < policy.config.rpcMaxRetries);
34+
} while (result.isFailure &&
35+
retryAttempt < policy.config.rpcMaxRetries &&
36+
_isRetryable(result));
3437

3538
return result;
3639
}
40+
41+
/// Returns false for permanent client errors (4xx except 408/429)
42+
/// that should not be retried.
43+
bool _isRetryable(Result<dynamic> result) {
44+
if (result is! Failure) return true;
45+
46+
final error = result.error;
47+
if (error is! VideoErrorWithCause) return true;
48+
49+
final cause = error.cause;
50+
if (cause is! ApiException) return true;
51+
52+
final statusCode = cause.code;
53+
if (statusCode >= 400 && statusCode < 500) {
54+
// 408 Request Timeout and 429 Too Many Requests are retryable
55+
return statusCode == 408 || statusCode == 429;
56+
}
57+
58+
return true;
59+
}
3760
}

packages/stream_video/lib/src/webrtc/peer_connection.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,14 @@ class StreamPeerConnection extends Disposable {
219219
final modifiedSdp = sdp != null
220220
? sdpEditor.edit(Sdp.localAnswer(sdp, offerSdp: offerSdp))
221221
: null;
222-
222+
223223
if (modifiedSdp == null || modifiedSdp.isEmpty) {
224224
_logger.w(
225225
() => '[createLocalAnswer] #$type; rejected (SDP is null/empty)',
226226
);
227227
return Result.error('createAnswer produced null/empty SDP');
228228
}
229-
229+
230230
final modifiedAnswer = localAnswer.copyWith(sdp: modifiedSdp);
231231

232232
_logger.v(

packages/stream_video/lib/src/webrtc/sdp/sdp.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ sealed class Sdp {
1010
static LocalAnswerSdp localAnswer(
1111
String sdp, {
1212
String? offerSdp,
13-
}) =>
14-
LocalAnswerSdp(sdp, offerSdp: offerSdp);
13+
}) => LocalAnswerSdp(sdp, offerSdp: offerSdp);
1514

1615
static RemoteOfferSdp remoteOffer(String sdp) => RemoteOfferSdp(sdp);
1716

0 commit comments

Comments
 (0)