import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/unauthorized.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/screens/channel/call/call.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class ChatCallProvider extends GetxController { Rx current = Rx(null); Rx channel = Rx(null); RxBool isReady = false.obs; RxBool isMounted = false.obs; RxBool isInitialized = false.obs; RxBool isBusy = false.obs; RxString lastDuration = '00:00:00'.obs; Timer? lastDurationUpdateTimer; String? token; String? endpoint; StreamSubscription? hwSubscription; RxList audioInputs = [].obs; RxList videoInputs = [].obs; RxBool enableAudio = true.obs; RxBool enableVideo = false.obs; Rx audioTrack = Rx(null); Rx videoTrack = Rx(null); Rx videoDevice = Rx(null); Rx audioDevice = Rx(null); late Room room; late EventsListener listener; RxList participantTracks = RxList.empty(growable: true); Rx focusTrack = Rx(null); void _updateDuration() { if (current.value == null) { lastDuration.value = '00:00:00'; return; } Duration duration = DateTime.now().difference(current.value!.createdAt); String twoDigits(int n) => n.toString().padLeft(2, '0'); String formattedTime = '${twoDigits(duration.inHours)}:' '${twoDigits(duration.inMinutes.remainder(60))}:' '${twoDigits(duration.inSeconds.remainder(60))}'; lastDuration.value = formattedTime; } void enableDurationUpdater() { _updateDuration(); lastDurationUpdateTimer = Timer.periodic( const Duration(seconds: 1), (_) => _updateDuration(), ); } void disableDurationUpdater() { lastDurationUpdateTimer?.cancel(); lastDurationUpdateTimer = null; } Future checkPermissions() async { if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) { return; } await Permission.camera.request(); await Permission.microphone.request(); await Permission.bluetooth.request(); await Permission.bluetoothConnect.request(); } void setCall(Call call, Channel related) { current.value = call; channel.value = related; } Future<(String, String)> getRoomToken() async { final AuthProvider auth = Get.find(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); final client = await auth.configureClient('messaging'); final resp = await client.post( '/channels/global/${channel.value!.alias}/calls/ongoing/token', {}, ); if (resp.statusCode == 200) { token = resp.body['token']; endpoint = 'wss://${resp.body['endpoint']}'; return (token!, endpoint!); } else { throw RequestException(resp); } } void initHardware() { if (isReady.value) { return; } else { isReady.value = true; } hwSubscription = Hardware.instance.onDeviceChange.stream.listen( revertDevices, ); Hardware.instance.enumerateDevices().then(revertDevices); } void initRoom() { initHardware(); room = Room( roomOptions: const RoomOptions( dynacast: true, adaptiveStream: true, defaultAudioPublishOptions: AudioPublishOptions( name: 'call_voice', stream: 'call_stream', ), defaultVideoPublishOptions: VideoPublishOptions( name: 'call_video', stream: 'call_stream', simulcast: true, backupVideoCodec: BackupVideoCodec(enabled: true), ), defaultScreenShareCaptureOptions: ScreenShareCaptureOptions( useiOSBroadcastExtension: true, params: VideoParametersPresets.screenShareH1080FPS30, ), defaultCameraCaptureOptions: CameraCaptureOptions( maxFrameRate: 30, params: VideoParametersPresets.h1080_169, ), ), ); listener = room.createListener(); WakelockPlus.enable(); } void joinRoom(String url, String token) async { if (isMounted.value) { return; } try { await room.connect( url, token, fastConnectOptions: FastConnectOptions( microphone: TrackOption(track: audioTrack.value), camera: TrackOption(track: videoTrack.value), ), ); } catch (e) { rethrow; } finally { isMounted.value = true; } } void autoPublish() async { try { if (enableVideo.value) { await room.localParticipant?.setCameraEnabled(true); } if (enableAudio.value) { await room.localParticipant?.setMicrophoneEnabled(true); } } catch (error) { rethrow; } } void onRoomDidUpdate() => sortParticipants(); void setupRoom() { if (isInitialized.value) return; sortParticipants(); room.addListener(onRoomDidUpdate); WidgetsBindingCompatible.instance?.addPostFrameCallback( (_) => autoPublish(), ); if (lkPlatformIsMobile()) { Hardware.instance.setSpeakerphoneOn(true); } isBusy.value = false; isInitialized.value = true; } void setupRoomListeners({ required Function(DisconnectReason?) onDisconnected, }) { listener ..on((event) async { onDisconnected(event.reason); }) ..on((event) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((event) { sortParticipants(); }); } void sortParticipants() { Map mediaTracks = {}; for (var participant in room.remoteParticipants.values) { mediaTracks[participant.sid] = ParticipantTrack( participant: participant, videoTrack: null, isScreenShare: false, ); for (var t in participant.videoTrackPublications) { mediaTracks[participant.sid]?.videoTrack = t.track; mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare; } } final newTracks = List.empty(growable: true); final mediaTrackList = mediaTracks.values.toList(); mediaTrackList.sort((a, b) { // Loudest people first if (a.participant.isSpeaking && b.participant.isSpeaking) { if (a.participant.audioLevel > b.participant.audioLevel) { return -1; } else { return 1; } } // Last spoke first final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; if (aSpokeAt != bSpokeAt) { return aSpokeAt > bSpokeAt ? -1 : 1; } // Has video first if (a.participant.hasVideo != b.participant.hasVideo) { return a.participant.hasVideo ? -1 : 1; } // First joined people first return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; }); newTracks.addAll(mediaTrackList); if (room.localParticipant != null) { ParticipantTrack localTrack = ParticipantTrack( participant: room.localParticipant!, videoTrack: null, isScreenShare: false, ); final localParticipantTracks = room.localParticipant?.videoTrackPublications; if (localParticipantTracks != null) { for (var t in localParticipantTracks) { localTrack.videoTrack = t.track; localTrack.isScreenShare = t.isScreenShare; } } newTracks.add(localTrack); } participantTracks.value = newTracks; if (focusTrack.value != null) { final idx = participantTracks.indexWhere( (x) => x.participant.sid == focusTrack.value!.participant.sid); if (idx == -1) { focusTrack.value = null; } } if (focusTrack.value == null) { focusTrack.value = participantTracks.firstOrNull; } else { final idx = participantTracks.indexWhere( (x) => focusTrack.value!.participant.sid == x.participant.sid, ); if (idx > -1) { focusTrack.value = participantTracks[idx]; } } } void revertDevices(List devices) async { audioInputs.clear(); audioInputs.addAll(devices.where((d) => d.kind == 'audioinput')); videoInputs.clear(); videoInputs.addAll(devices.where((d) => d.kind == 'videoinput')); if (audioInputs.isNotEmpty) { if (audioDevice.value == null && enableAudio.value) { audioDevice.value = audioInputs.first; Future.delayed(const Duration(milliseconds: 100), () async { await changeLocalAudioTrack(); }); } } if (videoInputs.isNotEmpty) { if (videoDevice.value == null && enableVideo.value) { videoDevice.value = videoInputs.first; Future.delayed(const Duration(milliseconds: 100), () async { await changeLocalVideoTrack(); }); } } } Future setEnableVideo(value) async { enableVideo.value = value; if (!enableVideo.value) { await videoTrack.value?.stop(); videoTrack.value = null; } else { await changeLocalVideoTrack(); } } Future setEnableAudio(value) async { enableAudio.value = value; if (!enableAudio.value) { await audioTrack.value?.stop(); audioTrack.value = null; } else { await changeLocalAudioTrack(); } } Future changeLocalAudioTrack() async { if (audioTrack.value != null) { await audioTrack.value!.stop(); audioTrack.value = null; } if (audioDevice.value != null) { audioTrack.value = await LocalAudioTrack.create( AudioCaptureOptions( deviceId: audioDevice.value!.deviceId, ), ); await audioTrack.value!.start(); } } Future changeLocalVideoTrack() async { if (videoTrack.value != null) { await videoTrack.value!.stop(); videoTrack.value = null; } if (videoDevice.value != null) { videoTrack.value = await LocalVideoTrack.createCameraTrack( CameraCaptureOptions( deviceId: videoDevice.value!.deviceId, params: VideoParametersPresets.h1080_169, ), ); await videoTrack.value!.start(); } } void changeFocusTrack(ParticipantTrack track) { focusTrack.value = track; } Future gotoScreen(BuildContext context) { return Navigator.of(context).push( MaterialPageRoute(builder: (context) => const CallScreen()), ); } void deactivateHardware() { hwSubscription?.cancel(); } void disposeRoom() { isBusy.value = false; isMounted.value = false; isInitialized.value = false; current.value = null; channel.value = null; room.removeListener(onRoomDidUpdate); room.disconnect(); room.dispose(); listener.dispose(); WakelockPlus.disable(); } void disposeHardware() { isReady.value = false; audioTrack.value?.stop(); audioTrack.value = null; videoTrack.value?.stop(); videoTrack.value = null; } }