webrtc implemented
parent
f9274e070e
commit
5670408c5e
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>multimedia / 5 - multimedia, audio, music, off, sound, sound off, speaker, volume icon</title>
|
||||
<g id="Free-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(-1043.000000, -304.000000)" id="Group" stroke="#000000" stroke-width="2">
|
||||
<g transform="translate(1041.000000, 302.000000)" id="Shape">
|
||||
<path d="M17,16.9590334 L17,20.9958147 L16,21 L10,15 L8.00491634,15 C6.90034684,15 6.00491634,14.1045695 6.00491634,13 L6.00491634,11 C6.00491634,9.8954305 6.90034684,9 8.00491634,9 L9.05605361,9 L9.05605361,9">
|
||||
</path>
|
||||
<polyline points="12.584864 6.78440227 16 3 17 3 17 11.0419196">
|
||||
</polyline>
|
||||
<line x1="3.07751425" y1="3.04297451" x2="21" y2="21">
|
||||
</line>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart';
|
||||
import 'package:test_sa/views/widgets/item_views/info_text_widget.dart';
|
||||
|
||||
import '../chat_provider.dart';
|
||||
import 'call_provider.dart';
|
||||
|
||||
class VideoCallPage extends StatefulWidget {
|
||||
final Participants sender;
|
||||
final Participants recipient;
|
||||
final int moduleId;
|
||||
final int? referenceID;
|
||||
final bool isAccept;
|
||||
|
||||
const VideoCallPage({Key? key, required this.sender, required this.recipient, this.isAccept = false, required this.moduleId, this.referenceID}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<VideoCallPage> createState() => _VideoCallPageState();
|
||||
}
|
||||
|
||||
class _VideoCallPageState extends State<VideoCallPage> {
|
||||
late CallProvider callPro;
|
||||
|
||||
bool isDialogOpen = false;
|
||||
bool isLocalVideoFullscreen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
callPro = Provider.of<CallProvider>(context, listen: false);
|
||||
makeCall();
|
||||
}
|
||||
|
||||
void makeCall() async {
|
||||
bool isConnected = false;
|
||||
try {
|
||||
isConnected = await callPro.buildHubConnection(widget.sender.employeeNumber ?? "", hubC: chatHubConnection);
|
||||
} catch (e) {
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (!isConnected) {
|
||||
_showFailure("Error while connecting to call service. Please try again later.", title: "Connection Error");
|
||||
return;
|
||||
}
|
||||
|
||||
callPro.initCallListeners();
|
||||
if (widget.isAccept) {
|
||||
callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", true, moduleId: widget.moduleId, onSuccess: (String msg) {
|
||||
_closeCall();
|
||||
}, onFailed: (String msg) {
|
||||
_showFailure(msg, title: "Failed");
|
||||
});
|
||||
} else {
|
||||
callPro.startCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", true, widget.moduleId, widget.referenceID, onSuccess: (String msg) {
|
||||
_closeCall();
|
||||
}, onFailed: (String msg) {
|
||||
_showFailure(msg, title: "Failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _closeCall() {
|
||||
callPro.endSession();
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _showFailure(String message, {required String title}) {
|
||||
callPro.endSession();
|
||||
if (!mounted || isDialogOpen) return;
|
||||
isDialogOpen = true;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
builder: (BuildContext cxt) => PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (bool didPop, Object? result) {
|
||||
if (didPop) return;
|
||||
},
|
||||
child: SingleBtnDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
okTitle: "Go Back",
|
||||
onTap: () {
|
||||
Navigator.pop(cxt);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}),
|
||||
),
|
||||
).then((_) {
|
||||
isDialogOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
callPro.endSession();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
_background(),
|
||||
// NOTE: the selector must include renderer/srcObject readiness, not
|
||||
// just isPeerCameraOn. isPeerCameraOn rarely changes during a call,
|
||||
// so a Selector keyed on it alone never rebuilds once the remote
|
||||
// video stream attaches later (after WebRTC negotiation finishes) -
|
||||
// which made the remote feed look permanently "off".
|
||||
Selector<CallProvider, (bool, bool)>(
|
||||
selector: (_, p) => (p.isPeerCameraOn, p.webRTCService.remoteRenderer?.srcObject != null),
|
||||
builder: (_, state, __) => isLocalVideoFullscreen ? _fullscreenLocalVideo() : _remoteVideo(state.$1),
|
||||
),
|
||||
isLocalVideoFullscreen ? _smallRemoteVideo() : _localVideoPreview(),
|
||||
_topBar(),
|
||||
_bottomControls(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _background() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF202641),
|
||||
Color(0xFF283859),
|
||||
Color(0xFF335073),
|
||||
Color(0xFF425E7A),
|
||||
Color(0xFF4A637D),
|
||||
Color(0xFF39495F),
|
||||
Color(0xFF1A2032),
|
||||
Color(0xFF121426),
|
||||
Color(0xFF121426),
|
||||
Color(0xFF25283A),
|
||||
Color(0xFF383D4D),
|
||||
],
|
||||
stops: [0.0, 0.08, 0.15, 0.23, 0.27, 0.34, 0.42, 0.51, 0.70, 0.89, 0.97],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _remoteVideo(bool isPeerCameraOn) {
|
||||
final renderer = callPro.webRTCService.remoteRenderer;
|
||||
final hasStream = renderer?.srcObject != null;
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => isLocalVideoFullscreen = true),
|
||||
child: !isPeerCameraOn || !hasStream
|
||||
? _avatarPlaceholder(widget.recipient.userName ?? '', large: true)
|
||||
: RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fullscreenLocalVideo() {
|
||||
return Selector<CallProvider, (bool, bool)>(
|
||||
selector: (_, p) => (p.isCameraOn, p.webRTCService.localRenderer?.srcObject != null),
|
||||
builder: (_, state, __) {
|
||||
final isCameraOn = state.$1;
|
||||
final renderer = callPro.webRTCService.localRenderer;
|
||||
final hasStream = renderer?.srcObject != null;
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => isLocalVideoFullscreen = false),
|
||||
child: !isCameraOn || !hasStream
|
||||
? const Center(child: Icon(Icons.videocam_off, color: Colors.white54, size: 64))
|
||||
: RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _localVideoPreview() {
|
||||
return Positioned(
|
||||
top: 90,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => isLocalVideoFullscreen = true),
|
||||
child: Selector<CallProvider, (bool, bool)>(
|
||||
selector: (_, p) => (p.isCameraOn, p.webRTCService.localRenderer?.srcObject != null),
|
||||
builder: (_, state, __) {
|
||||
final isCameraOn = state.$1;
|
||||
final renderer = callPro.webRTCService.localRenderer;
|
||||
final hasStream = renderer?.srcObject != null;
|
||||
return _previewBox(
|
||||
child: !isCameraOn || !hasStream
|
||||
? const Center(child: Icon(Icons.videocam_off, color: Colors.white, size: 28))
|
||||
: RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _smallRemoteVideo() {
|
||||
return Positioned(
|
||||
top: 90,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => isLocalVideoFullscreen = false),
|
||||
child: Selector<CallProvider, (bool, bool)>(
|
||||
selector: (_, p) => (p.isPeerCameraOn, p.webRTCService.remoteRenderer?.srcObject != null),
|
||||
builder: (_, state, __) {
|
||||
final isPeerCameraOn = state.$1;
|
||||
final renderer = callPro.webRTCService.remoteRenderer;
|
||||
final hasStream = renderer?.srcObject != null;
|
||||
return _previewBox(
|
||||
child: !isPeerCameraOn || !hasStream
|
||||
? _avatarPlaceholder(widget.recipient.userName ?? '', large: false)
|
||||
: RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _previewBox({required Widget child}) {
|
||||
return Container(
|
||||
width: 110,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff414352),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: .2), width: 1),
|
||||
),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(11), child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatarPlaceholder(String name, {required bool large}) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person, size: large ? 80 : 36, color: Colors.white54),
|
||||
if (large && name.isNotEmpty) ...[
|
||||
12.height,
|
||||
InfoTextLabelWidget(label: name, textStyle: AppTextStyles.heading5, color: Colors.white),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
16.height,
|
||||
InfoTextLabelWidget(label: widget.recipient.userName ?? '', textStyle: AppTextStyles.heading4.copyWith(fontWeight: FontWeight.w600), color: Colors.white),
|
||||
4.height,
|
||||
Selector<CallProvider, String>(
|
||||
selector: (_, p) => p.currentStatus == "Connected" ? p.formattedCallDuration : p.currentStatus,
|
||||
builder: (_, status, __) => InfoTextLabelWidget(label: status, textStyle: AppTextStyles.heading6, color: Colors.white),
|
||||
),
|
||||
4.height,
|
||||
Selector<CallProvider, bool>(
|
||||
selector: (_, p) => p.isPeerMuted,
|
||||
builder: (_, isPeerMuted, __) => isPeerMuted ? InfoTextLabelWidget(label: "Mic Mute", textStyle: AppTextStyles.tinyFont, color: AppColor.red30) : const SizedBox(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomControls() {
|
||||
return Positioned(
|
||||
left: 24,
|
||||
right: 24,
|
||||
bottom: 50,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Selector<CallProvider, bool>(
|
||||
selector: (_, p) => p.isMuted,
|
||||
builder: (_, isMuted, __) => callButton(isMuted ? "calling/mic_disable" : "calling/mic_enable", () => callPro.toggleMute()),
|
||||
),
|
||||
Selector<CallProvider, bool>(
|
||||
selector: (_, p) => p.isCameraOn,
|
||||
builder: (_, isCameraOn, __) => callButton(isCameraOn ? "calling/video_enable" : "calling/video_disable", () => callPro.toggleCamera()),
|
||||
),
|
||||
callButton("calling/toggle_camera", () => callPro.switchCamera()),
|
||||
callButton("calling/end_call", () {
|
||||
callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId);
|
||||
Navigator.pop(context);
|
||||
}, isEndCall: true, size: 68),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget callButton(String icon, VoidCallback onTap, {bool isEndCall = false, double size = 52}) {
|
||||
return (isEndCall
|
||||
? Container(
|
||||
height: size,
|
||||
width: size,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: const BoxDecoration(color: AppColor.red30, shape: BoxShape.circle),
|
||||
child: icon.toSvgAsset(height: 24, width: 24, color: Colors.white),
|
||||
)
|
||||
: Container(
|
||||
height: size,
|
||||
width: size,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: const Color(0xff414352), shape: BoxShape.circle, border: Border.all(color: Colors.white.withValues(alpha: .2), width: 1)),
|
||||
child: icon.toSvgAsset(height: 24, width: 24, color: Colors.white),
|
||||
))
|
||||
.onPress(onTap);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,635 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
class WebRTCService {
|
||||
// Peer connection
|
||||
RTCPeerConnection? _peerConnection;
|
||||
|
||||
// Media streams
|
||||
MediaStream? _localStream;
|
||||
MediaStream? _remoteStream;
|
||||
|
||||
// Video renderers (for future video support)
|
||||
RTCVideoRenderer? localRenderer;
|
||||
RTCVideoRenderer? remoteRenderer;
|
||||
|
||||
// Callbacks
|
||||
Function(MediaStream stream)? onLocalStream;
|
||||
Function(MediaStream stream)? onRemoteStream;
|
||||
Function(RTCIceCandidate candidate)? onIceCandidate;
|
||||
Function(RTCIceConnectionState state)? onIceConnectionStateChange;
|
||||
Function()? onCallEnded;
|
||||
|
||||
final List<RTCIceCandidate> _iceCandidateQueue = [];
|
||||
bool _remoteDescriptionSet = false;
|
||||
|
||||
Completer<void>? _initializationCompleter;
|
||||
bool _isFullyInitialized = false;
|
||||
|
||||
bool _isVideoCall = false;
|
||||
|
||||
static bool _useTestMode = false;
|
||||
|
||||
bool get isFullyInitialized => _isFullyInitialized;
|
||||
bool get isPeerConnectionInitialized => _peerConnection != null && _isFullyInitialized;
|
||||
Future<void> waitForInitialization() async {
|
||||
if (_isFullyInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initializationCompleter != null) {
|
||||
await _initializationCompleter!.future;
|
||||
}
|
||||
}
|
||||
|
||||
static void enableTestMode() {
|
||||
_useTestMode = true;
|
||||
}
|
||||
|
||||
static void disableTestMode() {
|
||||
_useTestMode = false;
|
||||
}
|
||||
|
||||
static const Map<String, dynamic> _mediaConstraints = {
|
||||
'audio': true,
|
||||
'video': false,
|
||||
};
|
||||
|
||||
static const Map<String, dynamic> _videoConstraints = {
|
||||
'audio': true,
|
||||
'video': {
|
||||
'facingMode': 'user',
|
||||
'width': {'ideal': 1280},
|
||||
'height': {'ideal': 720},
|
||||
},
|
||||
};
|
||||
|
||||
static const Map<String, dynamic> _testConfiguration = {
|
||||
'iceServers': [
|
||||
{
|
||||
'urls': [
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:stun1.l.google.com:19302',
|
||||
'stun:stun2.l.google.com:19302',
|
||||
]
|
||||
},
|
||||
],
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'iceTransportPolicy': 'all',
|
||||
'iceCandidatePoolSize': 10,
|
||||
};
|
||||
//Need to move the to safe place..
|
||||
|
||||
final Map<String, dynamic> _configuration = {
|
||||
'iceServers': [
|
||||
{
|
||||
'urls': [
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:stun1.l.google.com:19302',
|
||||
]
|
||||
},
|
||||
|
||||
// Backend TURN server (PRIMARY)
|
||||
{
|
||||
'urls': [
|
||||
'turn:15.185.116.59:3479',
|
||||
'turn:15.185.116.59:3479?transport=tcp',
|
||||
],
|
||||
'username': 'admin',
|
||||
'credential': 'admin'
|
||||
},
|
||||
|
||||
// FALLBACK: Public TURN servers
|
||||
{
|
||||
'urls': 'turn:a.relay.metered.ca:80',
|
||||
'username': 'e14d93f87f9ff517f1bee797',
|
||||
'credential': 'PGfCGmR6CR2aM9OY',
|
||||
},
|
||||
{
|
||||
'urls': 'turn:a.relay.metered.ca:443',
|
||||
'username': 'e14d93f87f9ff517f1bee797',
|
||||
'credential': 'PGfCGmR6CR2aM9OY',
|
||||
},
|
||||
],
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'iceTransportPolicy': 'all',
|
||||
'iceCandidatePoolSize': 10,
|
||||
'bundlePolicy': 'max-bundle',
|
||||
'rtcpMuxPolicy': 'require',
|
||||
};
|
||||
|
||||
// Initialize WebRTC for audio call
|
||||
Future<void> initializeForAudioCall() async {
|
||||
if (_isFullyInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
_initializationCompleter = Completer<void>();
|
||||
_isFullyInitialized = false;
|
||||
|
||||
try {
|
||||
_isVideoCall = false;
|
||||
try {
|
||||
_localStream = await navigator.mediaDevices.getUserMedia(
|
||||
_mediaConstraints,
|
||||
);
|
||||
|
||||
final audioTracks = _localStream!.getAudioTracks();
|
||||
|
||||
for (var i = 0; i < audioTracks.length; i++) {
|
||||
final track = audioTracks[i];
|
||||
|
||||
if (!track.enabled) {
|
||||
track.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty) {
|
||||
throw Exception(
|
||||
'No audio tracks captured from microphone',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'Microphone access denied or unavailable',
|
||||
);
|
||||
}
|
||||
_peerConnection = await createPeerConnection(
|
||||
_configuration,
|
||||
);
|
||||
|
||||
if (_peerConnection == null) {
|
||||
throw Exception(
|
||||
'Failed to create peer connection',
|
||||
);
|
||||
}
|
||||
_setupPeerConnectionListeners();
|
||||
|
||||
final tracks = _localStream!.getTracks();
|
||||
|
||||
for (var i = 0; i < tracks.length; i++) {
|
||||
final track = tracks[i];
|
||||
|
||||
await _peerConnection!.addTrack(
|
||||
track,
|
||||
_localStream!,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await Helper.setSpeakerphoneOn(false);
|
||||
} catch (e) {}
|
||||
|
||||
_isFullyInitialized = true;
|
||||
|
||||
if (!_initializationCompleter!.isCompleted) {
|
||||
_initializationCompleter!.complete();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log('[WebRTC] Audio init error: $e', name: 'WebRTCService');
|
||||
|
||||
if (_initializationCompleter != null &&
|
||||
!_initializationCompleter!.isCompleted) {
|
||||
_initializationCompleter!.completeError(
|
||||
e,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
_isFullyInitialized = false;
|
||||
|
||||
await dispose();
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
//video call initialization
|
||||
Future<void> initializeForVideoCall() async {
|
||||
if (_isFullyInitialized) {
|
||||
return;
|
||||
}
|
||||
_initializationCompleter = Completer<void>();
|
||||
_isFullyInitialized = false;
|
||||
|
||||
try {
|
||||
_isVideoCall = true;
|
||||
|
||||
// Initialize video renderers
|
||||
localRenderer = RTCVideoRenderer();
|
||||
remoteRenderer = RTCVideoRenderer();
|
||||
|
||||
await localRenderer!.initialize();
|
||||
await remoteRenderer!.initialize();
|
||||
|
||||
// Get camera + microphone stream first
|
||||
try {
|
||||
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
|
||||
} catch (e) {
|
||||
throw Exception('Camera or microphone access denied or unavailable');
|
||||
}
|
||||
|
||||
if (_localStream == null) {
|
||||
throw Exception('Local video stream is null');
|
||||
}
|
||||
|
||||
for (final track in _localStream!.getTracks()) {
|
||||
if (!track.enabled) {
|
||||
track.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach local stream to renderer
|
||||
localRenderer!.srcObject = _localStream;
|
||||
|
||||
// Create peer connection
|
||||
final config = _useTestMode ? _testConfiguration : _configuration;
|
||||
|
||||
_peerConnection = await createPeerConnection(config);
|
||||
|
||||
if (_peerConnection == null) {
|
||||
throw Exception('Failed to create peer connection');
|
||||
}
|
||||
|
||||
// Setup listeners
|
||||
_setupPeerConnectionListeners();
|
||||
|
||||
// Add local tracks
|
||||
final tracks = _localStream!.getTracks();
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
throw Exception('No media tracks found');
|
||||
}
|
||||
|
||||
for (final track in tracks) {
|
||||
await _peerConnection!.addTrack(
|
||||
track,
|
||||
_localStream!,
|
||||
);
|
||||
}
|
||||
|
||||
_isFullyInitialized = true;
|
||||
|
||||
if (!_initializationCompleter!.isCompleted) {
|
||||
_initializationCompleter!.complete();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log('[WebRTC] Video init error: $e', name: 'WebRTCService');
|
||||
|
||||
if (_initializationCompleter != null &&
|
||||
!_initializationCompleter!.isCompleted) {
|
||||
_initializationCompleter!.completeError(
|
||||
e,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
_isFullyInitialized = false;
|
||||
|
||||
await dispose();
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup peer connection event listener
|
||||
void _setupPeerConnectionListeners() {
|
||||
_peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
|
||||
onIceCandidate?.call(candidate);
|
||||
};
|
||||
|
||||
_peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {};
|
||||
|
||||
_peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
|
||||
log('[WEBRTC] ICE connection state: $state', name: 'WebRTCService');
|
||||
onIceConnectionStateChange?.call(state);
|
||||
};
|
||||
|
||||
_peerConnection!.onTrack = (RTCTrackEvent event) {
|
||||
if (event.streams.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stream = event.streams.first;
|
||||
|
||||
if (_remoteStream == null || _remoteStream!.id != stream.id) {
|
||||
_remoteStream = stream;
|
||||
|
||||
if (_isVideoCall && remoteRenderer != null) {
|
||||
try {
|
||||
remoteRenderer!.srcObject = _remoteStream;
|
||||
|
||||
// Safety refresh for renderer race condition
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
|
||||
remoteRenderer!.srcObject = _remoteStream;
|
||||
}
|
||||
});
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
|
||||
remoteRenderer!.srcObject = _remoteStream;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
log('[WEBRTC] Remote renderer error: $e', name: 'WebRTCService');
|
||||
}
|
||||
}
|
||||
|
||||
onRemoteStream?.call(_remoteStream!);
|
||||
} else {
|
||||
// Refresh renderer if stream exists but UI missed it
|
||||
if (_isVideoCall && remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
|
||||
try {
|
||||
remoteRenderer!.srcObject = _remoteStream;
|
||||
} catch (e) {
|
||||
log('[WEBRTC] Renderer refresh failed: $e', name: 'WebRTCService');
|
||||
}
|
||||
}
|
||||
|
||||
onRemoteStream?.call(_remoteStream!);
|
||||
}
|
||||
};
|
||||
|
||||
_peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
|
||||
log('[WEBRTC] Connection state: $state', name: 'WebRTCService');
|
||||
|
||||
if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||
onCallEnded?.call();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Create SDP offer (caller side)
|
||||
Future<RTCSessionDescription> createOffer() async {
|
||||
try {
|
||||
if (_peerConnection == null) {
|
||||
throw Exception('Peer connection not initialized');
|
||||
}
|
||||
|
||||
final offer = await _peerConnection!.createOffer({
|
||||
'offerToReceiveAudio': true,
|
||||
'offerToReceiveVideo': _isVideoCall,
|
||||
'iceRestart': false,
|
||||
});
|
||||
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [WebRTC] Error creating offer: $e\nStack: $stackTrace', name: 'WebRTCService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create SDP answer (callee side)
|
||||
Future<RTCSessionDescription> createAnswer(String offerSdp) async {
|
||||
try {
|
||||
if (_peerConnection == null) {
|
||||
throw Exception('Peer connection not initialized');
|
||||
}
|
||||
|
||||
final offer = RTCSessionDescription(offerSdp, 'offer');
|
||||
await _peerConnection!.setRemoteDescription(offer);
|
||||
_remoteDescriptionSet = true;
|
||||
|
||||
await _flushIceCandidateQueue();
|
||||
|
||||
final answer = await _peerConnection!.createAnswer({
|
||||
'offerToReceiveAudio': true,
|
||||
'offerToReceiveVideo': _isVideoCall,
|
||||
});
|
||||
|
||||
await _peerConnection!.setLocalDescription(answer);
|
||||
|
||||
return answer;
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [WebRTC] Error creating answer: $e\nStack: $stackTrace', name: 'WebRTCService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set remote answer (caller side)
|
||||
Future<void> setRemoteAnswer(String answerSdp) async {
|
||||
try {
|
||||
if (_peerConnection == null) {
|
||||
throw Exception('Peer connection not initialized');
|
||||
}
|
||||
|
||||
final answer = RTCSessionDescription(answerSdp, 'answer');
|
||||
await _peerConnection!.setRemoteDescription(answer);
|
||||
_remoteDescriptionSet = true;
|
||||
|
||||
await _flushIceCandidateQueue();
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [WebRTC] Error setting remote answer: $e\nStack: $stackTrace', name: 'WebRTCService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send SDP answer (convenience method for callee)
|
||||
Future<RTCSessionDescription> sendSDPAnswer(String? offerSdp) async {
|
||||
if (offerSdp == null) {
|
||||
throw Exception('Offer SDP is null');
|
||||
}
|
||||
return await createAnswer(offerSdp);
|
||||
}
|
||||
|
||||
/// Add remote ICE candidate
|
||||
Future<void> addIceCandidate(RTCIceCandidate candidate) async {
|
||||
try {
|
||||
// If peer connection not created yet, queue the candidate
|
||||
if (_peerConnection == null) {
|
||||
_iceCandidateQueue.add(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
// If remote description not set yet, queue the candidate
|
||||
if (!_remoteDescriptionSet) {
|
||||
_iceCandidateQueue.add(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
await _peerConnection!.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService');
|
||||
// Don't rethrow - ICE candidate errors shouldn't break the call
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush queued ICE candidates
|
||||
Future<void> _flushIceCandidateQueue() async {
|
||||
if (_iceCandidateQueue.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final candidate in _iceCandidateQueue) {
|
||||
try {
|
||||
await _peerConnection!.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService');
|
||||
}
|
||||
}
|
||||
|
||||
_iceCandidateQueue.clear();
|
||||
}
|
||||
|
||||
/// Toggle microphone mute
|
||||
void setMicrophoneMuted(bool muted) {
|
||||
if (_localStream != null) {
|
||||
final audioTracks = _localStream!.getAudioTracks();
|
||||
for (final track in audioTracks) {
|
||||
track.enabled = !muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle camera on/off for video calls
|
||||
void setCameraEnabled(bool enabled) {
|
||||
if (_localStream != null) {
|
||||
final videoTracks = _localStream!.getVideoTracks();
|
||||
for (final track in videoTracks) {
|
||||
track.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch between front and rear camera
|
||||
Future<void> switchCamera() async {
|
||||
if (_localStream != null) {
|
||||
final videoTracks = _localStream!.getVideoTracks();
|
||||
if (videoTracks.isNotEmpty) {
|
||||
try {
|
||||
await Helper.switchCamera(videoTracks.first);
|
||||
} catch (e) {
|
||||
log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable/disable speakerphone
|
||||
Future<void> setSpeakerphoneEnabled(bool enabled) async {
|
||||
try {
|
||||
await Helper.setSpeakerphoneOn(enabled);
|
||||
} catch (e) {
|
||||
log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get local stream
|
||||
MediaStream? get localStream => _localStream;
|
||||
|
||||
/// Get remote stream
|
||||
MediaStream? get remoteStream => _remoteStream;
|
||||
|
||||
/// Check if remote stream has video tracks
|
||||
bool get hasRemoteVideo {
|
||||
if (_remoteStream == null) return false;
|
||||
return _remoteStream!.getVideoTracks().isNotEmpty;
|
||||
}
|
||||
|
||||
/// Force refresh remote renderer
|
||||
void refreshRemoteRenderer() {
|
||||
if (_remoteStream != null && remoteRenderer != null && _isVideoCall) {
|
||||
if (remoteRenderer!.srcObject == null) {
|
||||
remoteRenderer!.srcObject = _remoteStream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart ICE negotiation (used when connection fails)
|
||||
Future<RTCSessionDescription?> restartIce() async {
|
||||
try {
|
||||
if (_peerConnection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final offer = await _peerConnection!.createOffer({
|
||||
'offerToReceiveAudio': true,
|
||||
'offerToReceiveVideo': _isVideoCall,
|
||||
'iceRestart': true,
|
||||
});
|
||||
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
} catch (e) {
|
||||
log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peer connection state
|
||||
RTCPeerConnectionState? getPeerConnectionState() {
|
||||
return _peerConnection?.connectionState;
|
||||
}
|
||||
|
||||
/// Get ICE connection state
|
||||
RTCIceConnectionState? getIceConnectionState() {
|
||||
return _peerConnection?.iceConnectionState;
|
||||
}
|
||||
|
||||
/// Dispose and cleanup
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
if (_localStream != null) {
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
track.stop();
|
||||
});
|
||||
await _localStream!.dispose();
|
||||
_localStream = null;
|
||||
}
|
||||
|
||||
if (_remoteStream != null) {
|
||||
_remoteStream!.getTracks().forEach((track) {
|
||||
track.stop();
|
||||
});
|
||||
await _remoteStream!.dispose();
|
||||
_remoteStream = null;
|
||||
}
|
||||
if (localRenderer != null) {
|
||||
|
||||
localRenderer!.srcObject = null;
|
||||
|
||||
await localRenderer!.dispose();
|
||||
|
||||
localRenderer = null;
|
||||
}
|
||||
|
||||
|
||||
if (remoteRenderer != null) {
|
||||
|
||||
remoteRenderer!.srcObject = null;
|
||||
|
||||
await remoteRenderer!.dispose();
|
||||
|
||||
remoteRenderer = null;
|
||||
}
|
||||
|
||||
|
||||
if (_peerConnection != null) {
|
||||
await _peerConnection!.close();
|
||||
await _peerConnection!.dispose();
|
||||
_peerConnection = null;
|
||||
}
|
||||
|
||||
_iceCandidateQueue.clear();
|
||||
_remoteDescriptionSet = false;
|
||||
|
||||
// Reset service state because this is a singleton
|
||||
_isFullyInitialized = false;
|
||||
_initializationCompleter = null;
|
||||
_isVideoCall = false;
|
||||
|
||||
} catch (e) {
|
||||
log(
|
||||
'⚠️ [WebRTC] Error during dispose: $e',
|
||||
name: 'WebRTCService',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue