Solian/lib/screens/chat/call.dart

490 lines
15 KiB
Dart
Raw Normal View History

2024-04-27 05:12:26 +00:00
import 'dart:async';
2024-04-26 17:36:54 +00:00
import 'dart:convert';
import 'package:flutter/material.dart';
2024-04-27 05:12:26 +00:00
import 'package:livekit_client/livekit_client.dart';
2024-04-26 17:36:54 +00:00
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
2024-04-28 11:36:06 +00:00
import 'package:solian/widgets/chat/call/controls.dart';
2024-04-27 05:12:26 +00:00
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/chat/call/participant.dart';
import 'package:solian/widgets/chat/call/participant_menu.dart';
2024-04-29 12:22:06 +00:00
import 'package:solian/widgets/exts.dart';
2024-04-26 17:36:54 +00:00
import 'package:solian/widgets/indent_wrapper.dart';
2024-04-27 05:12:26 +00:00
import 'package:permission_handler/permission_handler.dart';
2024-04-26 17:36:54 +00:00
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
2024-04-27 12:10:15 +00:00
import 'package:wakelock_plus/wakelock_plus.dart';
2024-04-27 05:12:26 +00:00
import 'dart:math' as math;
2024-04-26 17:36:54 +00:00
class ChatCall extends StatefulWidget {
final Call call;
const ChatCall({super.key, required this.call});
@override
State<ChatCall> createState() => _ChatCallState();
}
class _ChatCallState extends State<ChatCall> {
String? _token;
2024-04-27 05:12:26 +00:00
String? _endpoint;
bool _isMounted = false;
StreamSubscription? _subscription;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
final VideoParameters _videoParameters = VideoParametersPresets.h720_169;
late Room _callRoom;
late EventsListener<RoomEvent> _callListener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusParticipant;
2024-04-27 05:12:26 +00:00
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return;
2024-04-28 13:49:03 +00:00
2024-04-27 05:12:26 +00:00
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
Future<(String, String)> exchangeToken() async {
await checkPermissions();
2024-04-26 17:36:54 +00:00
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
router.pop();
throw Error();
}
var uri = getRequestUri('messaging', '/api/channels/${widget.call.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'];
2024-04-27 05:12:26 +00:00
_endpoint = 'wss://${result['endpoint']}';
joinRoom(_endpoint!, _token!);
return (_token!, _endpoint!);
2024-04-26 17:36:54 +00:00
} else {
var message = utf8.decode(res.bodyBytes);
2024-04-29 12:22:06 +00:00
context.showErrorDialog(message);
2024-04-26 17:36:54 +00:00
throw Exception(message);
}
}
2024-04-27 05:12:26 +00:00
void joinRoom(String url, String token) async {
2024-04-27 12:10:15 +00:00
if (_isMounted) {
2024-04-27 05:12:26 +00:00
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 _callRoom.connect(
url,
token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: const VideoPublishOptions(
name: 'call_video',
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();
} catch (e) {
2024-04-29 12:22:06 +00:00
context.showErrorDialog(e);
2024-04-27 05:12:26 +00:00
} finally {
notify.close();
}
}
2024-04-27 12:10:15 +00:00
void autoPublish() async {
2024-04-27 05:12:26 +00:00
try {
2024-04-27 16:21:16 +00:00
if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true);
2024-04-27 05:12:26 +00:00
} catch (error) {
await context.showErrorDialog(error);
}
try {
2024-04-27 16:21:16 +00:00
if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true);
2024-04-27 05:12:26 +00:00
} catch (error) {
await context.showErrorDialog(error);
}
}
void setupRoom() {
_callRoom.addListener(onRoomDidUpdate);
setupRoomListeners();
sortParticipants();
2024-04-27 12:10:15 +00:00
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish());
2024-04-27 05:12:26 +00:00
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
}
void setupRoomListeners() {
_callListener
..on<RoomDisconnectedEvent>((event) async {
if (event.reason != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Call disconnected... ${event.reason}'),
));
}
if (router.canPop()) router.pop();
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
})
..on<AudioPlaybackStatusChanged>((event) async {
if (!_callRoom.canPlaybackAudio) {
bool? yesno = await context.showPlayAudioManuallyDialog();
if (yesno == true) {
await _callRoom.startAudio();
}
}
});
}
void sortParticipants() {
2024-04-29 12:22:06 +00:00
Map<String, ParticipantTrack> mediaTracks = {};
2024-04-27 05:12:26 +00:00
for (var participant in _callRoom.remoteParticipants.values) {
2024-04-29 12:22:06 +00:00
mediaTracks[participant.sid] = ParticipantTrack(
2024-04-28 11:36:06 +00:00
participant: participant,
videoTrack: null,
isScreenShare: false,
);
for (var t in participant.videoTrackPublications) {
2024-04-29 12:22:06 +00:00
mediaTracks[participant.sid]?.videoTrack = t.track;
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
2024-04-27 05:12:26 +00:00
}
}
2024-04-28 11:36:06 +00:00
2024-04-29 12:22:06 +00:00
final mediaTrackList = mediaTracks.values.toList();
mediaTrackList.sort((a, b) {
2024-04-27 05:12:26 +00:00
// 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;
});
2024-04-28 11:36:06 +00:00
ParticipantTrack localTrack = ParticipantTrack(
participant: _callRoom.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
if (_callRoom.localParticipant != null) {
final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
2024-04-29 12:22:06 +00:00
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
2024-04-27 05:12:26 +00:00
}
}
}
2024-04-27 16:21:16 +00:00
2024-04-27 05:12:26 +00:00
setState(() {
2024-04-29 12:22:06 +00:00
_participantTracks = [localTrack, ...mediaTrackList];
if (_focusParticipant == null) {
_focusParticipant = _participantTracks.first;
} else {
final idx = _participantTracks.indexWhere((x) => _focusParticipant!.participant.sid == x.participant.sid);
_focusParticipant = _participantTracks[idx];
}
2024-04-27 05:12:26 +00:00
});
}
void onRoomDidUpdate() => sortParticipants();
void revertDevices(List<MediaDevice> 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();
setState(() {});
});
}
}
if (_videoInputs.isNotEmpty) {
if (_videoDevice == null && _enableVideo) {
_videoDevice = _videoInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalVideoTrack();
setState(() {});
});
}
}
setState(() {});
}
Future<void> setEnableVideo(value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await changeLocalVideoTrack();
}
setState(() {});
}
Future<void> setEnableAudio(value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await changeLocalAudioTrack();
}
setState(() {});
}
Future<void> 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<void> 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();
}
}
@override
void initState() {
super.initState();
_subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
_callRoom = Room();
_callListener = _callRoom.createListener();
Hardware.instance.enumerateDevices().then(revertDevices);
2024-04-27 12:10:15 +00:00
WakelockPlus.enable();
2024-04-27 05:12:26 +00:00
}
2024-04-26 17:36:54 +00:00
@override
Widget build(BuildContext context) {
return IndentWrapper(
title: AppLocalizations.of(context)!.chatCall,
hideDrawer: true,
child: FutureBuilder(
future: exchangeToken(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
}
2024-04-27 05:12:26 +00:00
return Stack(
children: [
Column(
children: [
Expanded(
2024-04-27 16:07:32 +00:00
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _focusParticipant != null
? InteractiveParticipantWidget(
participant: _focusParticipant!,
onTap: () {},
)
2024-04-27 16:07:32 +00:00
: Container(),
),
2024-04-27 05:12:26 +00:00
),
2024-04-27 16:07:32 +00:00
if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!),
2024-04-27 05:12:26 +00:00
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
2024-04-27 05:12:26 +00:00
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, _participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = _participantTracks[index];
if (track.participant.sid == _focusParticipant?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid != _focusParticipant?.participant.sid) {
setState(() => _focusParticipant = track);
}
},
),
2024-04-27 16:21:16 +00:00
),
);
},
2024-04-27 05:12:26 +00:00
),
),
),
],
);
2024-04-26 17:36:54 +00:00
},
),
);
}
2024-04-27 05:12:26 +00:00
@override
void deactivate() {
_subscription?.cancel();
super.deactivate();
}
@override
void dispose() {
2024-04-27 12:10:15 +00:00
WakelockPlus.disable();
2024-04-27 05:12:26 +00:00
(() async {
_callRoom.removeListener(onRoomDidUpdate);
await _callListener.dispose();
2024-04-27 16:07:32 +00:00
await _callRoom.disconnect();
2024-04-27 05:12:26 +00:00
await _callRoom.dispose();
})();
super.dispose();
}
2024-04-26 17:36:54 +00:00
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
2024-04-29 12:22:06 +00:00
final bool? isFixed;
final ParticipantTrack participant;
final Function() onTap;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
2024-04-29 12:22:06 +00:00
this.isFixed = false,
required this.participant,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
child: Container(
width: width,
height: height,
color: color,
2024-04-29 12:22:06 +00:00
child: ParticipantWidget.widgetFor(participant, isFixed: true),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
);
}
}