import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/chat/call/exts.dart'; import 'package:solian/widgets/exts.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class ChatProvider extends ChangeNotifier { bool isOpened = false; bool isShown = false; ChatCallInstance? call; Future connect(AuthProvider auth) async { if (auth.client == null) await auth.loadClient(); if (!await auth.isAuthorized()) return null; await auth.refreshToken(); var ori = getRequestUri('messaging', '/api/ws'); var uri = Uri( scheme: ori.scheme.replaceFirst('http', 'ws'), host: ori.host, path: ori.path, queryParameters: { 'tk': Uri.encodeComponent(auth.client!.credentials.accessToken) }, ); final channel = WebSocketChannel.connect(uri); await channel.ready; return channel; } bool handleCall(Call call, Channel channel, {Function? onUpdate, Function? onDispose}) { if (this.call != null) return false; this.call = ChatCallInstance( onUpdate: () { notifyListeners(); if (onUpdate != null) onUpdate(); }, onDispose: () { this.call = null; notifyListeners(); if (onDispose != null) onDispose(); }, channel: channel, info: call, ); return true; } void setShown(bool state) { isShown = state; notifyListeners(); } } class ChatCallInstance { final Function onUpdate; final Function onDispose; final Call info; final Channel channel; bool isMounted = false; String? token; String? endpoint; StreamSubscription? subscription; List audioInputs = []; List videoInputs = []; bool enableAudio = true; bool enableVideo = false; LocalAudioTrack? audioTrack; LocalVideoTrack? videoTrack; MediaDevice? videoDevice; MediaDevice? audioDevice; final VideoParameters videoParameters = VideoParametersPresets.h720_169; late Room room; late EventsListener listener; List participantTracks = []; ParticipantTrack? focusTrack; ChatCallInstance({ required this.onUpdate, required this.onDispose, required this.channel, required this.info, }); void init() { subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices); room = Room(); listener = room.createListener(); Hardware.instance.enumerateDevices().then(revertDevices); WakelockPlus.enable(); } 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(); } Future<(String, String)> exchangeToken(BuildContext context) async { await checkPermissions(); final auth = context.read(); if (!await auth.isAuthorized()) { onDispose(); throw Exception("unauthorized"); } var uri = getRequestUri( 'messaging', '/api/channels/${channel.alias}/calls/ongoing/token'); var res = await auth.client!.post(uri); if (res.statusCode == 200) { final result = jsonDecode(utf8.decode(res.bodyBytes)); token = result['token']; endpoint = 'wss://${result['endpoint']}'; joinRoom(context, endpoint!, token!); return (token!, endpoint!); } else { var message = utf8.decode(res.bodyBytes); context.showErrorDialog(message); throw Exception(message); } } void joinRoom(BuildContext context, String url, String token) async { if (isMounted) { return; } else { isMounted = true; } ScaffoldMessenger.of(context).clearSnackBars(); final notify = ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.connectingServer), duration: const Duration(minutes: 1), ), ); try { await room.connect( url, token, roomOptions: RoomOptions( dynacast: true, adaptiveStream: true, defaultAudioPublishOptions: const AudioPublishOptions( name: 'call_voice', stream: 'call_stream', ), defaultVideoPublishOptions: const VideoPublishOptions( name: 'callvideo', stream: 'call_stream', simulcast: true, backupVideoCodec: BackupVideoCodec(enabled: true), ), defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( useiOSBroadcastExtension: true, params: VideoParameters( dimensions: VideoDimensionsPresets.h1080_169, encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30), ), ), defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: videoParameters), ), fastConnectOptions: FastConnectOptions( microphone: TrackOption(track: audioTrack), camera: TrackOption(track: videoTrack), ), ); setupRoom(context); } catch (e) { context.showErrorDialog(e); } finally { notify.close(); } } void autoPublish(BuildContext context) async { try { if (enableVideo) await room.localParticipant?.setCameraEnabled(true); } catch (error) { await context.showErrorDialog(error); } try { if (enableAudio) await room.localParticipant?.setMicrophoneEnabled(true); } catch (error) { await context.showErrorDialog(error); } } void setupRoom(BuildContext context) { room.addListener(onRoomDidUpdate); setupRoomListeners(context); sortParticipants(); WidgetsBindingCompatible.instance ?.addPostFrameCallback((_) => autoPublish(context)); if (lkPlatformIsMobile()) { Hardware.instance.setSpeakerphoneOn(true); } } void setupRoomListeners(BuildContext context) { listener ..on((event) async { if (event.reason != null) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Call disconnected... ${event.reason}'), )); } onDispose(); }) ..on((event) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((_) => sortParticipants()) ..on((event) { sortParticipants(); }) ..on((event) async { if (!room.canPlaybackAudio) { bool? yesno = await context.showPlayAudioManuallyDialog(); if (yesno == true) { await room.startAudio(); } } }); } 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 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; }); ParticipantTrack localTrack = ParticipantTrack( participant: room.localParticipant!, videoTrack: null, isScreenShare: false, ); if (room.localParticipant != null) { final localParticipantTracks = room.localParticipant?.videoTrackPublications; if (localParticipantTracks != null) { for (var t in localParticipantTracks) { localTrack.videoTrack = t.track; localTrack.isScreenShare = t.isScreenShare; } } } participantTracks = [localTrack, ...mediaTrackList]; if (focusTrack == null) { focusTrack = participantTracks.first; } else { final idx = participantTracks .indexWhere((x) => focusTrack!.participant.sid == x.participant.sid); focusTrack = participantTracks[idx]; } onUpdate(); } void revertDevices(List devices) async { audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); if (audioInputs.isNotEmpty) { if (audioDevice == null && enableAudio) { audioDevice = audioInputs.first; Future.delayed(const Duration(milliseconds: 100), () async { await changeLocalAudioTrack(); onUpdate(); }); } } if (videoInputs.isNotEmpty) { if (videoDevice == null && enableVideo) { videoDevice = videoInputs.first; Future.delayed(const Duration(milliseconds: 100), () async { await changeLocalVideoTrack(); onUpdate(); }); } } onUpdate(); } Future setEnableVideo(value) async { enableVideo = value; if (!enableVideo) { await videoTrack?.stop(); videoTrack = null; } else { await changeLocalVideoTrack(); } onUpdate(); } Future setEnableAudio(value) async { enableAudio = value; if (!enableAudio) { await audioTrack?.stop(); audioTrack = null; } else { await changeLocalAudioTrack(); } onUpdate(); } Future changeLocalAudioTrack() async { if (audioTrack != null) { await audioTrack!.stop(); audioTrack = null; } if (audioDevice != null) { audioTrack = await LocalAudioTrack.create(AudioCaptureOptions( deviceId: audioDevice!.deviceId, )); await audioTrack!.start(); } } Future changeLocalVideoTrack() async { if (videoTrack != null) { await videoTrack!.stop(); videoTrack = null; } if (videoDevice != null) { videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions( deviceId: videoDevice!.deviceId, params: videoParameters, )); await videoTrack!.start(); } } void changeFocusTrack(ParticipantTrack track) { focusTrack = track; onUpdate(); } void onRoomDidUpdate() => sortParticipants(); void deactivate() { subscription?.cancel(); } void dispose() { room.removeListener(onRoomDidUpdate); (() async { await listener.dispose(); await room.disconnect(); await room.dispose(); })(); WakelockPlus.disable(); onDispose(); } }