Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/stream_video/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Upcoming

### ✅ Added
* Added video moderation support by providing `VideoModerationConfig` in `CallPreferences`. Chech [cookbook](https://getstream.io/video/docs/flutter/ui-cookbook/call-moderation/) for more details.

## 1.2.4

### 🐞 Fixed
Expand Down
83 changes: 83 additions & 0 deletions packages/stream_video/lib/src/call/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ class Call {

final Map<String, Timer> _reactionTimers = {};
final Map<String, Timer> _captionsTimers = {};
Timer? _videoModerationTimer;
void Function()? _onModerationBlurApply;
void Function()? _onModerationBlurClear;
final List<CancelableOperation<void>> _sfuStatsTimers = [];
final Set<SfuClientCapability> _sfuClientCapabilities = {
SfuClientCapability.subscriberVideoPause, // on by default
Expand Down Expand Up @@ -623,11 +626,77 @@ class Call {
event.metadata,
updateMembers: false,
);
case StreamCallModerationWarningEvent _:
return _handleModerationWarningEvent(event);
case StreamCallModerationBlurEvent _:
return _handleModerationBlurEvent(event);
default:
break;
}
}

void _handleModerationWarningEvent(
StreamCallModerationWarningEvent event,
) {
final config = state.value.preferences.videoModerationConfig;
if (config.isDisabled || event.userId != _streamVideo.currentUser.id) {
return;
}

config.onWarning?.call(event.message);
}

Future<void> _handleModerationBlurEvent(
StreamCallModerationBlurEvent event,
) async {
final config = state.value.preferences.videoModerationConfig;
if (config.isDisabled) return;

_stateManager.coordinatorCallModerationBlur(event.userId);

_videoModerationTimer?.cancel();
_videoModerationTimer = null;
if (config.duration != null) {
_videoModerationTimer = Timer(config.duration!, clearModerationBlur);
}

if (config.muteAudio) await setMicrophoneEnabled(enabled: false);
if (config.muteVideo) await setCameraEnabled(enabled: false);
if (config.applyBlur) _onModerationBlurApply?.call();
config.onApply?.call();
}

/// Clears the moderation action, restoring normal operation.
///
/// When [VideoModerationConfig.muteAudio] / [VideoModerationConfig.muteVideo]
/// were active, re-enabling mic/camera is allowed again but they stay off
/// until the user manually re-enables them.
void clearModerationBlur() {
_videoModerationTimer?.cancel();
_videoModerationTimer = null;

if (!state.value.isVideoModerated) return;

final config = state.value.preferences.videoModerationConfig;
_stateManager.clearModerationBlur();

if (config.applyBlur) _onModerationBlurClear?.call();
config.onClear?.call();
}

/// Registers handlers for the native blur effect pipeline.
///
/// Called automatically by `StreamVideoEffectsManager` from
/// `stream_video_filters` when [VideoModerationConfig.applyBlur] is true.
@internal
void setModerationBlurEffectHandlers({
required void Function() onApply,
required void Function() onClear,
}) {
_onModerationBlurApply = onApply;
_onModerationBlurClear = onClear;
}

@internal
void traceSessionLog(String tag, dynamic data) {
_session?.trace(tag, data);
Expand Down Expand Up @@ -1851,6 +1920,8 @@ class Call {
]) {
timer.cancel();
}
_videoModerationTimer?.cancel();
_videoModerationTimer = null;

for (final operation in _sfuStatsTimers) {
await operation.cancel();
Expand Down Expand Up @@ -2930,6 +3001,12 @@ class Call {
required bool enabled,
CameraConstraints? constraints,
}) async {
if (enabled &&
state.value.isVideoModerated &&
state.value.preferences.videoModerationConfig.muteVideo) {
_logger.w(() => '[setCameraEnabled] blocked by video moderation');
return Result.error('Blocked by video moderation');
}
if (enabled && !hasPermission(CallPermission.sendVideo)) {
return Result.error('Missing permission to send video');
}
Expand Down Expand Up @@ -3006,6 +3083,12 @@ class Call {
required bool enabled,
AudioConstraints? constraints,
}) async {
if (enabled &&
state.value.isVideoModerated &&
state.value.preferences.videoModerationConfig.muteAudio) {
_logger.w(() => '[setMicrophoneEnabled] blocked by video moderation');
return Result.error('Blocked by video moderation');
}
if (enabled && !hasPermission(CallPermission.sendAudio)) {
return Result.error('Missing permission to send audio');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,26 @@ mixin StateCoordinatorMixin on StateNotifier<CallState> {
.toList(),
);
}

void coordinatorCallModerationBlur(
String userId,
) {
if (userId != state.currentUserId) {
_logger.i(
() => '[coordinatorCallModeration] rejected (not current user)',
);
return;
}

state = state.copyWith(
isVideoModerated: true,
);
}

void clearModerationBlur() {
_logger.i(() => '[clearModerationBlur]');
state = state.copyWith(
isVideoModerated: false,
);
}
}
9 changes: 9 additions & 0 deletions packages/stream_video/lib/src/call_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class CallState extends Equatable {
anonymousParticipantCount: 0,
iOSMultitaskingCameraAccessEnabled: false,
custom: const {},
isVideoModerated: false,
);
}

Expand Down Expand Up @@ -92,6 +93,7 @@ class CallState extends Equatable {
required this.anonymousParticipantCount,
required this.iOSMultitaskingCameraAccessEnabled,
required this.custom,
required this.isVideoModerated,
});

final CallPreferences preferences;
Expand Down Expand Up @@ -131,6 +133,9 @@ class CallState extends Equatable {
final bool iOSMultitaskingCameraAccessEnabled;
final Map<String, Object> custom;

/// Whether the local user's video is currently blurred by moderation.
final bool isVideoModerated;

String get callId => callCid.id;

StreamCallType get callType => callCid.type;
Expand Down Expand Up @@ -201,6 +206,7 @@ class CallState extends Equatable {
int? anonymousParticipantCount,
bool? iOSMultitaskingCameraAccessEnabled,
Map<String, Object>? custom,
bool? isVideoModerated,
}) {
return CallState._(
preferences: preferences ?? this.preferences,
Expand Down Expand Up @@ -242,6 +248,7 @@ class CallState extends Equatable {
iOSMultitaskingCameraAccessEnabled ??
this.iOSMultitaskingCameraAccessEnabled,
custom: custom ?? this.custom,
isVideoModerated: isVideoModerated ?? this.isVideoModerated,
);
}

Expand Down Expand Up @@ -314,13 +321,15 @@ class CallState extends Equatable {
anonymousParticipantCount,
iOSMultitaskingCameraAccessEnabled,
custom,
isVideoModerated,
];

@override
String toString() {
return 'CallState(status: $status, currentUserId: $currentUserId,'
' callCid: $callCid, createdByUser: $createdByUser,'
' sessionId: $sessionId, isRecording: $isRecording,'
' isVideoModerated: $isVideoModerated,'
' settings: $settings, egress: $egress, '
' videoInputDevice: $videoInputDevice,'
' audioInputDevice: $audioInputDevice,'
Expand Down
12 changes: 12 additions & 0 deletions packages/stream_video/lib/src/models/call_preferences.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'call_client_publish_options.dart';
import 'moderation_blur_config.dart';

abstract class CallPreferences {
/// The maximum duration to wait when establishing a connection to the call.
Expand Down Expand Up @@ -49,6 +50,10 @@ abstract class CallPreferences {
/// The maximum number of closed caption lines that can be visible
/// simultaneously on screen.
int get closedCaptionsVisibleCaptions;

/// Configuration for how the SDK handles call moderation events.
/// Defaults to [VideoModerationConfig.disabled].
VideoModerationConfig get videoModerationConfig;
}

class DefaultCallPreferences implements CallPreferences {
Expand All @@ -62,6 +67,7 @@ class DefaultCallPreferences implements CallPreferences {
this.clientPublishOptions,
this.closedCaptionsVisibilityDurationMs = 2700,
this.closedCaptionsVisibleCaptions = 2,
this.videoModerationConfig = const VideoModerationConfig.disabled(),
});

/// The maximum duration to wait when establishing a connection to the call.
Expand Down Expand Up @@ -137,4 +143,10 @@ class DefaultCallPreferences implements CallPreferences {
/// Defaults to 2 lines.
@override
final int closedCaptionsVisibleCaptions;

/// Configuration for how the SDK handles call moderation events.
///
/// Defaults to [VideoModerationConfig.disabled].
@override
final VideoModerationConfig videoModerationConfig;
}
1 change: 1 addition & 0 deletions packages/stream_video/lib/src/models/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export 'call_status.dart';
export 'call_track_state.dart';
export 'disconnect_reason.dart';
export 'guest_created_data.dart';
export 'moderation_blur_config.dart';
export 'push_device.dart';
export 'push_provider.dart';
export 'queried_calls.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:ui';

/// Configures how the SDK handles `call.moderation` events.
///
/// Convenience constructors [VideoModerationConfig.disabled],
/// [VideoModerationConfig.mute], and [VideoModerationConfig.blur] cover
/// the most common presets.
class VideoModerationConfig {
const VideoModerationConfig({
this.muteAudio = false,
this.muteVideo = false,
this.applyBlur = false,
this.duration,
this.onApply,
this.onWarning,
this.onClear,
});

/// No automatic action. The event is still emitted on the call's event
/// stream for manual handling via `call.callEvents`.
const VideoModerationConfig.disabled()
: muteAudio = false,
muteVideo = false,
applyBlur = false,
duration = null,
onApply = null,
onWarning = null,
onClear = null;

/// Mutes the local user's microphone and camera, and prevents re-enabling
/// them for the configured [duration]. If [duration] is null, the mute
/// persists until `call.clearModerationBlur()` is called.
const VideoModerationConfig.mute({this.duration})
: muteAudio = true,
muteVideo = true,
applyBlur = false,
onApply = null,
onWarning = null,
onClear = null;

/// Applies a full-frame native blur on the camera track via
/// `StreamVideoEffectsManager` from the `stream_video_filters` package.
/// The blur is visible to ALL participants because it modifies frames
/// before encoding. Requires the `stream_video_filters` package.
const VideoModerationConfig.blur({this.duration})
: muteAudio = false,
muteVideo = false,
applyBlur = true,
onApply = null,
onWarning = null,
onClear = null;

final bool muteAudio;
final bool muteVideo;
final bool applyBlur;

final Duration? duration;

final VoidCallback? onApply;
final void Function(String)? onWarning;
final VoidCallback? onClear;

/// Whether this config takes no automatic action.
bool get isDisabled =>
!muteAudio &&
!muteVideo &&
!applyBlur &&
onApply == null &&
onWarning == null &&
onClear == null;
}
25 changes: 24 additions & 1 deletion packages/stream_video/lib/src/retry/retry_manager.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../open_api/video/coordinator/api.dart';
import '../errors/video_error.dart';
import '../utils/result.dart';
import 'retry_policy.dart';
Expand Down Expand Up @@ -30,8 +31,30 @@ class RpcRetryManager {
delegate,
);
retryAttempt++;
} while (result.isFailure && retryAttempt < policy.config.rpcMaxRetries);
} while (result.isFailure &&
retryAttempt < policy.config.rpcMaxRetries &&
_isRetryable(result));

return result;
}

/// Returns false for permanent client errors (4xx except 408/429)
/// that should not be retried.
bool _isRetryable(Result<dynamic> result) {
if (result is! Failure) return true;

final error = result.error;
if (error is! VideoErrorWithCause) return true;

final cause = error.cause;
if (cause is! ApiException) return true;

final statusCode = cause.code;
if (statusCode >= 400 && statusCode < 500) {
// 408 Request Timeout and 429 Too Many Requests are retryable
return statusCode == 408 || statusCode == 429;
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel.Result
import io.getstream.webrtc.flutter.videoEffects.ProcessorProvider
import io.getstream.video.flutter.stream_video_filters.factories.BackgroundBlurFactory
import io.getstream.video.flutter.stream_video_filters.factories.BlurIntensity
import io.getstream.video.flutter.stream_video_filters.factories.FullFrameBlurFactory
import io.getstream.video.flutter.stream_video_filters.factories.VirtualBackgroundFactory

/**
Expand Down Expand Up @@ -63,6 +64,14 @@ class StreamVideoFiltersPlugin: FlutterPlugin, MethodCallHandler {
)
}

result.success(null)
}
"registerFullFrameBlurEffectProcessor" -> {
ProcessorProvider.addProcessor(
"FullFrameBlur",
FullFrameBlurFactory()
)

result.success(null)
}
else -> {
Expand Down
Loading