feat(videocall): complete signaling integration, group call support, and UI polish

- Wire VIDEO_CALL_REQUEST/CANCEL/REFUSE/ROOM_COUNT commands via CommandsRepository
- Add VideocallChatType enum (single/multi) with chatType stored in state
- Implement auto-login to Juphoon SDK using sanitized email + user UUID
- Add runtime camera/microphone permissions before call start
- Add RetryInterceptor for transient TLS/socket errors in Dio
- Migrate VideocallItem to Freezed with isTalking extension
- Implement startGroupCall/leaveGroupCall using ChannelService with participant grid
- Add PopScope to intercept back navigation during active calls
- Redesign idle screen with device option cards and group call button
- Redesign active call UI with video overlay, PiP local view, and new controls layout
- Clean up SDK wrapper: remove unused streams, merge destroy+dispose into shutdown
- Add i18n keys for videocall UI across 6 locales
This commit is contained in:
2026-04-26 21:52:00 +02:00
parent 5aa0c0acc7
commit 555a668481
40 changed files with 1344 additions and 530 deletions

View File

@@ -3,6 +3,8 @@ import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'retry_interceptor.dart';
Future<Dio> buildDioClient({
required String baseUrl,
required String origin,
@@ -25,6 +27,7 @@ Future<Dio> buildDioClient({
final jar = cookieJar ?? await buildPersistCookieJar();
dio.interceptors.add(CookieManager(jar));
dio.interceptors.add(RetryInterceptor(dio));
if (log) {
dio.interceptors.add(

View File

@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:dio/dio.dart';
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxRetries = 2, this.retryDelay = const Duration(seconds: 1)});
final Dio _dio;
final int maxRetries;
final Duration retryDelay;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!_isTransientError(err)) {
return handler.next(err);
}
final attempt = _getAttempt(err.requestOptions);
if (attempt >= maxRetries) {
return handler.next(err);
}
_retry(err, handler, attempt + 1);
}
bool _isTransientError(DioException err) {
final error = err.error;
if (error is HandshakeException) return true;
if (error is SocketException) return true;
if (err.type == DioExceptionType.connectionError) return true;
if (err.type == DioExceptionType.connectionTimeout) return true;
return false;
}
int _getAttempt(RequestOptions options) {
return options.extra['_retryAttempt'] as int? ?? 0;
}
Future<void> _retry(DioException err, ErrorInterceptorHandler handler, int attempt) async {
await Future<void>.delayed(retryDelay * attempt);
final options = err.requestOptions;
options.extra['_retryAttempt'] = attempt;
try {
final response = await _dio.fetch(options);
handler.resolve(response);
} on DioException catch (retryError) {
handler.next(retryError);
}
}
}

View File

@@ -711,6 +711,14 @@
"videocallErrorCamera": "Kameraberechtigung erforderlich",
"videocallErrorMic": "Mikrofonberechtigung erforderlich",
"videocallErrorMissedCall": "Verpasster Anruf",
"videocallCallEnded": "Anruf beendet",
"videocallGroupCall": "Gruppen-Videoanruf",
"videocallDisclaimer": "*Für einen optimalen Videoanruf stellen Sie sicher, dass Ihre Uhr über mobile Daten mit Internetverbindung, guter Abdeckung oder WLAN-Verbindung verfügt.",
"videocallConnecting": "Warten auf Verbindungsaufbau...",
"videocallMute": "stummschalten",
"videocallSpeakerphone": "Freisprechen",
"videocallCameraOff": "Kamera ausschalten",
"videocallCameraOn": "Kamera einschalten",
"positionUpdated": "Letzte verfügbare Position aktualisiert",
"locationMapStyleLight": "Hell",
"locationMapStyleDark": "Dunkel",

View File

@@ -891,6 +891,14 @@
"videocallErrorCamera": "Camera permission required",
"videocallErrorMic": "Microphone permission required",
"videocallErrorMissedCall": "Missed call",
"videocallCallEnded": "Call ended",
"videocallGroupCall": "Group video call",
"videocallDisclaimer": "*For an optimal video call, make sure your watch has mobile data with Internet connection, good coverage or WiFi connection.",
"videocallConnecting": "Waiting for the connection to be established...",
"videocallMute": "mute",
"videocallSpeakerphone": "speakerphone",
"videocallCameraOff": "turn off camera",
"videocallCameraOn": "turn on camera",
"positionUpdated": "Updated to latest available position",
"locationMapStyleLight": "Light",
"locationMapStyleDark": "Dark",

View File

@@ -892,6 +892,14 @@
"videocallErrorCamera": "Se requiere permiso de cámara",
"videocallErrorMic": "Se requiere permiso de micrófono",
"videocallErrorMissedCall": "Llamada perdida",
"videocallCallEnded": "Llamada finalizada",
"videocallGroupCall": "Videollamada grupal",
"videocallDisclaimer": "*Para una llamada de video óptima, asegúrate de que tu reloj tenga datos móviles con conexión a Internet, buena cobertura o conexión wifi.",
"videocallConnecting": "Esperando a que se establezca la conexión...",
"videocallMute": "silenciar",
"videocallSpeakerphone": "manos libres",
"videocallCameraOff": "apagar la cámara",
"videocallCameraOn": "encender la cámara",
"positionUpdated": "Última posición disponible actualizada",
"locationMapStyleLight": "Claro",
"locationMapStyleDark": "Oscuro",

View File

@@ -711,6 +711,14 @@
"videocallErrorCamera": "Autorisation de la caméra requise",
"videocallErrorMic": "Autorisation du microphone requise",
"videocallErrorMissedCall": "Appel manqué",
"videocallCallEnded": "Appel terminé",
"videocallGroupCall": "Appel vidéo de groupe",
"videocallDisclaimer": "*Pour un appel vidéo optimal, assurez-vous que votre montre dispose de données mobiles avec connexion Internet, bonne couverture ou connexion WiFi.",
"videocallConnecting": "En attente de la connexion...",
"videocallMute": "couper le son",
"videocallSpeakerphone": "haut-parleur",
"videocallCameraOff": "éteindre la caméra",
"videocallCameraOn": "allumer la caméra",
"positionUpdated": "Dernière position disponible mise à jour",
"locationMapStyleLight": "Clair",
"locationMapStyleDark": "Sombre",

View File

@@ -711,6 +711,14 @@
"videocallErrorCamera": "Permesso fotocamera richiesto",
"videocallErrorMic": "Permesso microfono richiesto",
"videocallErrorMissedCall": "Chiamata persa",
"videocallCallEnded": "Chiamata terminata",
"videocallGroupCall": "Videochiamata di gruppo",
"videocallDisclaimer": "*Per una videochiamata ottimale, assicurati che il tuo orologio abbia dati mobili con connessione Internet, buona copertura o connessione WiFi.",
"videocallConnecting": "In attesa della connessione...",
"videocallMute": "silenzia",
"videocallSpeakerphone": "vivavoce",
"videocallCameraOff": "spegni fotocamera",
"videocallCameraOn": "accendi fotocamera",
"positionUpdated": "Ultima posizione disponibile aggiornata",
"locationMapStyleLight": "Chiaro",
"locationMapStyleDark": "Scuro",

View File

@@ -711,6 +711,14 @@
"videocallErrorCamera": "Permissão de câmara necessária",
"videocallErrorMic": "Permissão de microfone necessária",
"videocallErrorMissedCall": "Chamada perdida",
"videocallCallEnded": "Chamada terminada",
"videocallGroupCall": "Videochamada em grupo",
"videocallDisclaimer": "*Para uma videochamada ideal, certifique-se de que o seu relógio tenha dados móveis com conexão à Internet, boa cobertura ou conexão WiFi.",
"videocallConnecting": "Aguardando conexão...",
"videocallMute": "silenciar",
"videocallSpeakerphone": "viva-voz",
"videocallCameraOff": "desligar câmara",
"videocallCameraOn": "ligar câmara",
"positionUpdated": "Última posição disponível atualizada",
"locationMapStyleLight": "Claro",
"locationMapStyleDark": "Escuro",

View File

@@ -1007,6 +1007,14 @@ class I18n {
static const String vibrationOnly = 'vibrationOnly';
static const String videoCall = 'videoCall';
static const String videocallAccept = 'videocallAccept';
static const String videocallCallEnded = 'videocallCallEnded';
static const String videocallGroupCall = 'videocallGroupCall';
static const String videocallDisclaimer = 'videocallDisclaimer';
static const String videocallConnecting = 'videocallConnecting';
static const String videocallMute = 'videocallMute';
static const String videocallSpeakerphone = 'videocallSpeakerphone';
static const String videocallCameraOff = 'videocallCameraOff';
static const String videocallCameraOn = 'videocallCameraOn';
static const String videocallCalling = 'videocallCalling';
static const String videocallCameraBack = 'videocallCameraBack';
static const String videocallCameraFront = 'videocallCameraFront';

View File

@@ -49,10 +49,9 @@ class VideocallSdkManager {
return true;
}
Future<void> destroy() async {
Future<void> shutdown() async {
if (!_initialized) return;
// Reverse order
await pushService.destroy();
await channelService.destroy();
await callService.destroy();
@@ -60,16 +59,13 @@ class VideocallSdkManager {
await deviceService.destroy();
await client.destroy();
_initialized = false;
}
void dispose() {
callService.dispose();
channelService.dispose();
pushService.dispose();
netService.dispose();
deviceService.dispose();
client.dispose();
_initialized = false;
}
}

View File

@@ -1,42 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'call_direction.dart';
import 'call_state.dart';
class VideocallItem {
const VideocallItem({
required this.userId,
required this.isVideo,
required this.direction,
required this.state,
this.uploadVideoStreamSelf = false,
this.uploadVideoStreamOther = false,
});
part 'videocall_item.freezed.dart';
final String userId;
final bool isVideo;
final CallDirection direction;
final VideocallState state;
final bool uploadVideoStreamSelf;
final bool uploadVideoStreamOther;
bool get isTalking => state == VideocallState.talking;
VideocallItem copyWith({
String? userId,
bool? isVideo,
CallDirection? direction,
VideocallState? state,
bool? uploadVideoStreamSelf,
bool? uploadVideoStreamOther,
}) {
return VideocallItem(
userId: userId ?? this.userId,
isVideo: isVideo ?? this.isVideo,
direction: direction ?? this.direction,
state: state ?? this.state,
uploadVideoStreamSelf:
uploadVideoStreamSelf ?? this.uploadVideoStreamSelf,
uploadVideoStreamOther:
uploadVideoStreamOther ?? this.uploadVideoStreamOther,
);
}
@freezed
abstract class VideocallItem with _$VideocallItem {
const factory VideocallItem({
required String userId,
required bool isVideo,
required CallDirection direction,
required VideocallState state,
@Default(false) bool uploadVideoStreamSelf,
@Default(false) bool uploadVideoStreamOther,
}) = _VideocallItem;
}
extension VideocallItemX on VideocallItem {
bool get isTalking => state == VideocallState.talking;
}

View File

@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'videocall_item.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VideocallItem {
String get userId; bool get isVideo; CallDirection get direction; VideocallState get state; bool get uploadVideoStreamSelf; bool get uploadVideoStreamOther;
/// Create a copy of VideocallItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VideocallItemCopyWith<VideocallItem> get copyWith => _$VideocallItemCopyWithImpl<VideocallItem>(this as VideocallItem, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VideocallItem&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.isVideo, isVideo) || other.isVideo == isVideo)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.state, state) || other.state == state)&&(identical(other.uploadVideoStreamSelf, uploadVideoStreamSelf) || other.uploadVideoStreamSelf == uploadVideoStreamSelf)&&(identical(other.uploadVideoStreamOther, uploadVideoStreamOther) || other.uploadVideoStreamOther == uploadVideoStreamOther));
}
@override
int get hashCode => Object.hash(runtimeType,userId,isVideo,direction,state,uploadVideoStreamSelf,uploadVideoStreamOther);
@override
String toString() {
return 'VideocallItem(userId: $userId, isVideo: $isVideo, direction: $direction, state: $state, uploadVideoStreamSelf: $uploadVideoStreamSelf, uploadVideoStreamOther: $uploadVideoStreamOther)';
}
}
/// @nodoc
abstract mixin class $VideocallItemCopyWith<$Res> {
factory $VideocallItemCopyWith(VideocallItem value, $Res Function(VideocallItem) _then) = _$VideocallItemCopyWithImpl;
@useResult
$Res call({
String userId, bool isVideo, CallDirection direction, VideocallState state, bool uploadVideoStreamSelf, bool uploadVideoStreamOther
});
}
/// @nodoc
class _$VideocallItemCopyWithImpl<$Res>
implements $VideocallItemCopyWith<$Res> {
_$VideocallItemCopyWithImpl(this._self, this._then);
final VideocallItem _self;
final $Res Function(VideocallItem) _then;
/// Create a copy of VideocallItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? userId = null,Object? isVideo = null,Object? direction = null,Object? state = null,Object? uploadVideoStreamSelf = null,Object? uploadVideoStreamOther = null,}) {
return _then(_self.copyWith(
userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,isVideo: null == isVideo ? _self.isVideo : isVideo // ignore: cast_nullable_to_non_nullable
as bool,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable
as CallDirection,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable
as VideocallState,uploadVideoStreamSelf: null == uploadVideoStreamSelf ? _self.uploadVideoStreamSelf : uploadVideoStreamSelf // ignore: cast_nullable_to_non_nullable
as bool,uploadVideoStreamOther: null == uploadVideoStreamOther ? _self.uploadVideoStreamOther : uploadVideoStreamOther // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [VideocallItem].
extension VideocallItemPatterns on VideocallItem {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VideocallItem value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VideocallItem() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VideocallItem value) $default,){
final _that = this;
switch (_that) {
case _VideocallItem():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VideocallItem value)? $default,){
final _that = this;
switch (_that) {
case _VideocallItem() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String userId, bool isVideo, CallDirection direction, VideocallState state, bool uploadVideoStreamSelf, bool uploadVideoStreamOther)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VideocallItem() when $default != null:
return $default(_that.userId,_that.isVideo,_that.direction,_that.state,_that.uploadVideoStreamSelf,_that.uploadVideoStreamOther);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String userId, bool isVideo, CallDirection direction, VideocallState state, bool uploadVideoStreamSelf, bool uploadVideoStreamOther) $default,) {final _that = this;
switch (_that) {
case _VideocallItem():
return $default(_that.userId,_that.isVideo,_that.direction,_that.state,_that.uploadVideoStreamSelf,_that.uploadVideoStreamOther);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String userId, bool isVideo, CallDirection direction, VideocallState state, bool uploadVideoStreamSelf, bool uploadVideoStreamOther)? $default,) {final _that = this;
switch (_that) {
case _VideocallItem() when $default != null:
return $default(_that.userId,_that.isVideo,_that.direction,_that.state,_that.uploadVideoStreamSelf,_that.uploadVideoStreamOther);case _:
return null;
}
}
}
/// @nodoc
class _VideocallItem implements VideocallItem {
const _VideocallItem({required this.userId, required this.isVideo, required this.direction, required this.state, this.uploadVideoStreamSelf = false, this.uploadVideoStreamOther = false});
@override final String userId;
@override final bool isVideo;
@override final CallDirection direction;
@override final VideocallState state;
@override@JsonKey() final bool uploadVideoStreamSelf;
@override@JsonKey() final bool uploadVideoStreamOther;
/// Create a copy of VideocallItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VideocallItemCopyWith<_VideocallItem> get copyWith => __$VideocallItemCopyWithImpl<_VideocallItem>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VideocallItem&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.isVideo, isVideo) || other.isVideo == isVideo)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.state, state) || other.state == state)&&(identical(other.uploadVideoStreamSelf, uploadVideoStreamSelf) || other.uploadVideoStreamSelf == uploadVideoStreamSelf)&&(identical(other.uploadVideoStreamOther, uploadVideoStreamOther) || other.uploadVideoStreamOther == uploadVideoStreamOther));
}
@override
int get hashCode => Object.hash(runtimeType,userId,isVideo,direction,state,uploadVideoStreamSelf,uploadVideoStreamOther);
@override
String toString() {
return 'VideocallItem(userId: $userId, isVideo: $isVideo, direction: $direction, state: $state, uploadVideoStreamSelf: $uploadVideoStreamSelf, uploadVideoStreamOther: $uploadVideoStreamOther)';
}
}
/// @nodoc
abstract mixin class _$VideocallItemCopyWith<$Res> implements $VideocallItemCopyWith<$Res> {
factory _$VideocallItemCopyWith(_VideocallItem value, $Res Function(_VideocallItem) _then) = __$VideocallItemCopyWithImpl;
@override @useResult
$Res call({
String userId, bool isVideo, CallDirection direction, VideocallState state, bool uploadVideoStreamSelf, bool uploadVideoStreamOther
});
}
/// @nodoc
class __$VideocallItemCopyWithImpl<$Res>
implements _$VideocallItemCopyWith<$Res> {
__$VideocallItemCopyWithImpl(this._self, this._then);
final _VideocallItem _self;
final $Res Function(_VideocallItem) _then;
/// Create a copy of VideocallItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? userId = null,Object? isVideo = null,Object? direction = null,Object? state = null,Object? uploadVideoStreamSelf = null,Object? uploadVideoStreamOther = null,}) {
return _then(_VideocallItem(
userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,isVideo: null == isVideo ? _self.isVideo : isVideo // ignore: cast_nullable_to_non_nullable
as bool,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable
as CallDirection,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable
as VideocallState,uploadVideoStreamSelf: null == uploadVideoStreamSelf ? _self.uploadVideoStreamSelf : uploadVideoStreamSelf // ignore: cast_nullable_to_non_nullable
as bool,uploadVideoStreamOther: null == uploadVideoStreamOther ? _self.uploadVideoStreamOther : uploadVideoStreamOther // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -11,8 +11,6 @@ import '../services/videocall_device_service.dart';
import '../services/videocall_net_service.dart';
import '../services/videocall_push_service.dart';
// -- Service providers (thin wrappers over GetIt) --
final videocallManagerProvider = Provider<VideocallSdkManager>((ref) {
return GetIt.I<VideocallSdkManager>();
});
@@ -42,8 +40,6 @@ final videocallNetServiceProvider = Provider<VideocallNetService>((ref) {
return GetIt.I<VideocallNetService>();
});
// -- Stream providers (for reactive UI consumption) --
final videocallClientStateProvider =
StreamProvider<VideocallClientState>((ref) {
return ref.watch(videocallClientProvider).stateStream;

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:jc_sdk/jc_sdk.dart';
import '../models/call_direction.dart';
@@ -12,16 +13,14 @@ class VideocallCallService with JCCallCallback {
VideocallCallService({
required VideocallClient client,
required VideocallDeviceService deviceService,
}) : _clientRef = client,
_deviceRef = deviceService;
}) : _clientRef = client,
_deviceRef = deviceService;
final VideocallClient _clientRef;
final VideocallDeviceService _deviceRef;
JCCall? _call;
JCCall? get call => _call;
// -- Streams --
final _callItemAddController = StreamController<VideocallItem>.broadcast();
Stream<VideocallItem> get callItemAddStream => _callItemAddController.stream;
@@ -37,25 +36,9 @@ class VideocallCallService with JCCallCallback {
final _missedCallController = StreamController<VideocallItem>.broadcast();
Stream<VideocallItem> get missedCallStream => _missedCallController.stream;
final _messageReceivedController =
StreamController<({String type, String content, JCCallItem item})>
.broadcast();
Stream<({String type, String content, JCCallItem item})>
get messageReceivedStream => _messageReceivedController.stream;
final _dtmfReceivedController =
StreamController<({JCCallItem item, int value})>.broadcast();
Stream<({JCCallItem item, int value})> get dtmfReceivedStream =>
_dtmfReceivedController.stream;
final _earlyMediaController = StreamController<JCCallItem>.broadcast();
Stream<JCCallItem> get earlyMediaStream => _earlyMediaController.stream;
VideocallItem? _currentItem;
VideocallItem? get currentItem => _currentItem;
// -- Lifecycle --
Future<bool> initialize() async {
final client = _clientRef.client;
final mediaDevice = _deviceRef.mediaDevice;
@@ -66,8 +49,6 @@ class VideocallCallService with JCCallCallback {
Future<bool> destroy() async => JCCall.destroy();
// -- Call actions --
Future<bool> startCall({
required String userId,
required bool isVideo,
@@ -89,14 +70,11 @@ class VideocallCallService with JCCallCallback {
return _call!.term(item, reason ?? JCCall.REASON_NONE, description ?? '');
}
Future<bool> termCall(
JCCallItem item, int reason, String description) async {
Future<bool> termCall(JCCallItem item, int reason, String description) async {
if (_call == null) return false;
return _call!.term(item, reason, description);
}
// -- Mute/Audio --
Future<bool> mute(JCCallItem item) async {
if (_call == null) return false;
return _call!.mute(item);
@@ -117,8 +95,6 @@ class VideocallCallService with JCCallCallback {
return _call!.setMicScale(item, scale);
}
// -- Hold --
Future<bool> hold(JCCallItem item) async {
if (_call == null) return false;
return _call!.hold(item);
@@ -129,15 +105,14 @@ class VideocallCallService with JCCallCallback {
return _call!.becomeActive(item);
}
// -- Video --
Future<bool> enableUploadVideoStream(JCCallItem item) async {
if (_call == null) return false;
return _call!.enableUploadVideoStream(item);
}
Future<JCMediaDeviceVideoCanvas?> startLocalVideo(
{int renderType = JCMediaDevice.RENDER_FULL_AUTO}) async {
Future<JCMediaDeviceVideoCanvas?> startLocalVideo({
int renderType = JCMediaDevice.RENDER_FULL_AUTO,
}) async {
final item = await _call?.getActiveCallItem();
if (item == null) return null;
return item.startSelfVideo(renderType);
@@ -149,8 +124,9 @@ class VideocallCallService with JCCallCallback {
return item.stopSelfVideo();
}
Future<JCMediaDeviceVideoCanvas?> startRemoteVideo(
{int renderType = JCMediaDevice.RENDER_FULL_CONTENT}) async {
Future<JCMediaDeviceVideoCanvas?> startRemoteVideo({
int renderType = JCMediaDevice.RENDER_FULL_CONTENT,
}) async {
final item = await _call?.getActiveCallItem();
if (item == null) return null;
return item.startOtherVideo(renderType);
@@ -162,10 +138,11 @@ class VideocallCallService with JCCallCallback {
return item.stopOtherVideo();
}
// -- Recording --
Future<bool> audioRecord(
JCCallItem item, bool enable, String filePath) async {
JCCallItem item,
bool enable,
String filePath,
) async {
if (_call == null) return false;
return _call!.audioRecord(item, enable, filePath);
}
@@ -182,13 +159,18 @@ class VideocallCallService with JCCallCallback {
}) async {
if (_call == null) return false;
return _call!.videoRecord(
item, enable, remote, width, height, filePath, bothAudio, keyframe);
item,
enable,
remote,
width,
height,
filePath,
bothAudio,
keyframe,
);
}
// -- Messaging --
Future<bool> sendMessage(
JCCallItem item, String type, String content) async {
Future<bool> sendMessage(JCCallItem item, String type, String content) async {
if (_call == null) return false;
return _call!.sendMessage(item, type, content);
}
@@ -198,14 +180,9 @@ class VideocallCallService with JCCallCallback {
return _call!.sendDtmf(item, value);
}
// -- Items --
Future<List<JCCallItem>?> getCallItems() async => _call?.getCallItems();
Future<JCCallItem?> getActiveCallItem() async =>
_call?.getActiveCallItem();
// -- Config --
Future<JCCallItem?> getActiveCallItem() async => _call?.getActiveCallItem();
Future<String> getStatistics() async {
if (_call == null) return '';
@@ -232,20 +209,13 @@ class VideocallCallService with JCCallCallback {
static Future<MediaConfig> generateMediaConfigByMode(int mode) =>
JCCall.generateByMode(mode);
// -- Dispose --
void dispose() {
_callItemAddController.close();
_callItemUpdateController.close();
_callItemRemoveController.close();
_missedCallController.close();
_messageReceivedController.close();
_dtmfReceivedController.close();
_earlyMediaController.close();
}
// -- Internal --
VideocallState _mapCallState(int state) {
if (state == JCCall.STATE_PENDING) return VideocallState.pending;
if (state == JCCall.STATE_CONNECTING) return VideocallState.connecting;
@@ -270,30 +240,36 @@ class VideocallCallService with JCCallCallback {
);
}
// -- JCCallCallback --
@override
void onCallItemAdd(JCCallItem item) {
debugPrint(
'[VideocallSDK] onCallItemAdd: userId=${item.getUserId()}, video=${item.getVideo()}, direction=${item.getDirection()}',
);
_currentItem = _buildItem(item, VideocallState.pending);
_callItemAddController.add(_currentItem!);
}
@override
void onCallItemUpdate(JCCallItem item, ChangeParam changeParam) {
_currentItem = _buildItem(item, _mapCallState(item.getState()));
final mappedState = _mapCallState(item.getState());
debugPrint(
'[VideocallSDK] onCallItemUpdate: rawState=${item.getState()}, mappedState=$mappedState, uploadSelf=${item.uploadVideoStreamSelf}, uploadOther=${item.uploadVideoStreamOther}',
);
_currentItem = _buildItem(item, mappedState);
_callItemUpdateController.add(_currentItem!);
}
@override
void onCallItemRemove(JCCallItem item, int reason, String description) {
debugPrint(
'[VideocallSDK] onCallItemRemove: reason=$reason, description=$description',
);
_currentItem = null;
_callItemRemoveController.add((reason: reason, description: description));
}
@override
void onMessageReceive(String type, String content, JCCallItem item) {
_messageReceivedController.add((type: type, content: content, item: item));
}
void onMessageReceive(String type, String content, JCCallItem item) {}
@override
void onMissedCallItem(JCCallItem item) {
@@ -301,14 +277,10 @@ class VideocallCallService with JCCallCallback {
}
@override
void onDtmfReceived(JCCallItem item, int value) {
_dtmfReceivedController.add((item: item, value: value));
}
void onDtmfReceived(JCCallItem item, int value) {}
@override
void onEarlyMediaReceived(JCCallItem item) {
_earlyMediaController.add(item);
}
void onEarlyMediaReceived(JCCallItem item) {}
@override
void onSipRingInfoReceived(JCCallItem item, String callSipType) {}

View File

@@ -17,7 +17,6 @@ class VideocallChannelService with JCMediaChannelCallback {
JCMediaChannel? _channel;
JCMediaChannel? get channel => _channel;
// -- Streams --
final _stateChangeController =
StreamController<({int state, int oldState})>.broadcast();
@@ -97,7 +96,6 @@ class VideocallChannelService with JCMediaChannelCallback {
Stream<({int operationId, bool result, int reason})>
get inviteSipUserResultStream => _inviteSipUserResultController.stream;
// -- Lifecycle --
Future<bool> initialize() async {
final client = _clientRef.client;
@@ -109,7 +107,6 @@ class VideocallChannelService with JCMediaChannelCallback {
Future<bool> destroy() async => JCMediaChannel.destroy();
// -- Channel actions --
Future<bool> join(String channelId, {JoinParam? joinParam}) async {
if (_channel == null) return false;
@@ -131,7 +128,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.query(channelId);
}
// -- Audio/Video streams --
Future<bool> enableUploadAudioStream(bool enable) async {
if (_channel == null) return false;
@@ -159,7 +155,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.requestScreenVideo(screenUri, pictureSize);
}
// -- Screen share / CDN / Recording --
Future<bool> enableScreenShare(
bool enable, ScreenShareParam? screenShareParam) async {
@@ -177,7 +172,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.enableRecord(enable, recordParam);
}
// -- Participants --
Future<List<JCMediaChannelParticipant>?> getParticipants() async =>
_channel?.getParticipants();
@@ -195,7 +189,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.inviteSipUser(userId, sipParam);
}
// -- Custom roles/state --
Future<bool> setCustomRole(int customRole,
{JCMediaChannelParticipant? participant}) async {
@@ -219,7 +212,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.getCustomState();
}
// -- Channel properties --
Future<int> setCustomProperty(String property) async {
if (_channel == null) return -1;
@@ -231,7 +223,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.getCustomProperty();
}
// -- Channel info --
Future<String> getChannelUri() async {
if (_channel == null) return '';
@@ -313,7 +304,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.getScreenUserId();
}
// -- Self participant --
Future<JCMediaChannelParticipant?> getSelfParticipant() async {
return _channel?.getSelfParticipant();
@@ -325,7 +315,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.subscribeParticipantAudio(participant, subscribe);
}
// -- Screen share video --
Future<JCMediaDeviceVideoCanvas?> startScreenShareVideo(
int renderType, int pictureSize) async {
@@ -337,7 +326,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.stopScreenShareVideo();
}
// -- Video ratio / resolution --
Future<bool> enableSelfVideoRatio(bool enable, double ratio) async {
if (_channel == null) return false;
@@ -349,7 +337,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.getMaxResolution();
}
// -- Messaging --
Future<bool> sendMessage(
String type, String content, String toUserId) async {
@@ -367,14 +354,12 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.sendCommandToDelivery(command);
}
// -- Statistics --
Future<String> getStatistics() async {
if (_channel == null) return '';
return _channel!.getStatistics();
}
// -- Volume change notify --
Future<bool> enableVolumeChangeNotify(bool value) async {
if (_channel == null) return false;
@@ -386,7 +371,6 @@ class VideocallChannelService with JCMediaChannelCallback {
return _channel!.getVolumeChangeNotify();
}
// -- Dispose --
void dispose() {
_stateChangeController.close();
@@ -403,7 +387,6 @@ class VideocallChannelService with JCMediaChannelCallback {
_inviteSipUserResultController.close();
}
// -- JCMediaChannelCallback --
@override
void onMediaChannelStateChange(int state, int oldState) =>

View File

@@ -13,7 +13,6 @@ class VideocallClient with JCClientCallback {
JCClient? _client;
JCClient? get client => _client;
// -- Streams --
final _stateController = StreamController<VideocallClientState>.broadcast();
Stream<VideocallClientState> get stateStream => _stateController.stream;
@@ -46,7 +45,6 @@ class VideocallClient with JCClientCallback {
VideocallClientState _state = VideocallClientState.notInitialized;
VideocallClientState get state => _state;
// -- Lifecycle --
Future<bool> initialize() async {
try {
@@ -75,7 +73,6 @@ class VideocallClient with JCClientCallback {
return result;
}
// -- Auth --
Future<bool> login({
required String userId,
@@ -100,7 +97,6 @@ class VideocallClient with JCClientCallback {
return _client!.logout();
}
// -- User info --
Future<String?> getUserId() async => _client?.getUserId();
@@ -118,7 +114,6 @@ class VideocallClient with JCClientCallback {
return _client!.getServerUid();
}
// -- Server config --
Future<bool> setServerAddress(String serverAddress) async {
if (_client == null) return false;
@@ -130,14 +125,12 @@ class VideocallClient with JCClientCallback {
return _client!.getServerAddress();
}
// -- Foreground/background --
Future<bool> setForeground(bool foreground) async {
if (_client == null) return false;
return _client!.setForeground(foreground);
}
// -- Online messaging --
Future<int> sendOnlineMessage({
required String userId,
@@ -147,7 +140,6 @@ class VideocallClient with JCClientCallback {
return _client!.sendOnlineMessage(userId, content);
}
// -- Params --
Future<CreateParam?> getCreateParam() async => _client?.getCreateParam();
@@ -158,7 +150,6 @@ class VideocallClient with JCClientCallback {
return _client!.getState();
}
// -- Dispose --
void dispose() {
_stateController.close();
@@ -169,7 +160,6 @@ class VideocallClient with JCClientCallback {
_serverMessageController.close();
}
// -- Internal --
void _updateState(VideocallClientState newState) {
_state = newState;
@@ -207,7 +197,6 @@ class VideocallClient with JCClientCallback {
return LoginFailureReason.unknown;
}
// -- JCClientCallback --
@override
void onClientStateChange(int state, int oldState) {

View File

@@ -12,7 +12,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
JCMediaDevice? _mediaDevice;
JCMediaDevice? get mediaDevice => _mediaDevice;
// -- Streams --
final _cameraUpdateController = StreamController<void>.broadcast();
Stream<void> get cameraUpdateStream => _cameraUpdateController.stream;
@@ -41,7 +40,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
final _audioResumeController = StreamController<void>.broadcast();
Stream<void> get audioResumeStream => _audioResumeController.stream;
// -- Lifecycle --
Future<bool> initialize() async {
final client = _clientRef.client;
@@ -52,7 +50,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
Future<bool> destroy() async => JCMediaDevice.destroy();
// -- Camera --
Future<bool> isCameraOpen() async {
if (_mediaDevice == null) return false;
@@ -106,7 +103,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.getCameraType(cameraIndex);
}
// -- Exposure --
Future<int> getMinExposureCompensation() async {
if (_mediaDevice == null) return 0;
@@ -133,7 +129,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.setExposureCompensation(level);
}
// -- Flash --
Future<bool> isCameraFlashSupported() async {
if (_mediaDevice == null) return false;
@@ -145,7 +140,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.enableFlash(enable);
}
// -- Focus/Zoom --
Future<bool> handleFocusMetering(
JCMediaDeviceVideoCanvas canvas, double xPercent, double yPercent) async {
@@ -168,7 +162,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.getCameraCurrentZoom();
}
// -- Speaker --
Future<bool> isSpeakerOn() async {
if (_mediaDevice == null) return false;
@@ -195,7 +188,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.getAudioRouteType();
}
// -- Audio --
Future<bool> isAudioStart() async {
if (_mediaDevice == null) return false;
@@ -245,7 +237,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
Future<JCMediaDeviceAudioParam?> getAudioParam() async =>
_mediaDevice?.getAudioParam();
// -- Volume --
Future<int> getOutputVolume() async {
if (_mediaDevice == null) return 0;
@@ -267,7 +258,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.removeVolumeCallback(callback);
}
// -- Video rendering --
Future<JCMediaDeviceVideoCanvas?> startCameraVideo(
{int renderType = JCMediaDevice.RENDER_FULL_AUTO}) async {
@@ -284,7 +274,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.stopVideo(canvas);
}
// -- Video file (custom capture) --
Future<bool> isVideoFileOpen() async {
if (_mediaDevice == null) return false;
@@ -313,7 +302,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.stopVideoFile();
}
// -- Video angle --
Future<bool> setVideoAngle(int angle) async {
if (_mediaDevice == null) return false;
@@ -325,7 +313,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.getVideoAngle();
}
// -- Frame callbacks --
Future<bool> setAudioFrameCallback(JCAudioFrameCallback? callback) async {
if (_mediaDevice == null) return false;
@@ -337,7 +324,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice!.setVideoFrameCallback(callback);
}
// -- Custom audio --
Future<bool> inputCustomAudioData(int sampleRateHz, int channels,
Uint8List byteBuffer, int playDelayMS, int recDelayMS, int clockDrift) async {
@@ -350,7 +336,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
return _mediaDevice?.getAudioOutputData(sampleRateHz, channels);
}
// -- Dispose --
void dispose() {
_cameraUpdateController.close();
@@ -362,7 +347,6 @@ class VideocallDeviceService with JCMediaDeviceCallback {
_audioResumeController.close();
}
// -- JCMediaDeviceCallback --
@override
void onCameraUpdate() => _cameraUpdateController.add(null);

View File

@@ -3,14 +3,12 @@ import 'dart:async';
import 'package:jc_sdk/jc_sdk.dart';
class VideocallNetService with JCNetCallback {
// -- Streams --
final _netChangeController =
StreamController<({int newNetType, int oldNetType})>.broadcast();
Stream<({int newNetType, int oldNetType})> get netChangeStream =>
_netChangeController.stream;
// -- Lifecycle --
void initialize() {
JCNet.getInstance().addCallback(this);
@@ -20,20 +18,17 @@ class VideocallNetService with JCNetCallback {
JCNet.getInstance().removeCallback(this);
}
// -- Network info --
Future<int> getNetType() async => JCNet.getInstance().getNetType();
Future<bool> hasNet() async => JCNet.getInstance().hasNet();
// -- Dispose --
void dispose() {
uninitialize();
_netChangeController.close();
}
// -- JCNetCallback --
@override
void onNetChange(int newNetType, int oldNetType) {

View File

@@ -11,7 +11,6 @@ class VideocallPushService {
JCPush? _push;
JCPush? get push => _push;
// -- Lifecycle --
Future<bool> initialize() async {
final client = _clientRef.client;
@@ -25,7 +24,6 @@ class VideocallPushService {
_push = null;
}
// -- Push --
Future<bool> addPushInfo(JCPushTemplate info) async {
if (_push == null) return false;
@@ -37,7 +35,6 @@ class VideocallPushService {
return _push!.addPushTemplate(data);
}
// -- Dispose --
void dispose() {
_push = null;

View File

@@ -14,8 +14,11 @@ dependencies:
jc_sdk: ^2.16.5
flutter_riverpod: ^3.0.3
get_it: ^9.0.5
freezed_annotation: ^3.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
freezed: ^3.0.6
build_runner: ^2.4.15