460 lines
12 KiB
Dart
460 lines
12 KiB
Dart
|
import 'dart:async';
|
||
|
|
||
|
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:surface/providers/sn_network.dart';
|
||
|
import 'package:surface/types/chat.dart';
|
||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||
|
|
||
|
class ChatCallProvider extends ChangeNotifier {
|
||
|
late final SnNetworkProvider _sn;
|
||
|
|
||
|
ChatCallProvider(BuildContext context) {
|
||
|
_sn = context.read<SnNetworkProvider>();
|
||
|
}
|
||
|
|
||
|
SnChatCall? _current;
|
||
|
SnChannel? _channel;
|
||
|
|
||
|
bool _isReady = false;
|
||
|
bool _isMounted = false;
|
||
|
bool _isInitialized = false;
|
||
|
bool _isBusy = false;
|
||
|
|
||
|
String _lastDuration = '00:00:00';
|
||
|
Timer? _lastDurationUpdateTimer;
|
||
|
|
||
|
String? token;
|
||
|
String? endpoint;
|
||
|
|
||
|
StreamSubscription? hwSubscription;
|
||
|
List<MediaDevice> _audioInputs = [];
|
||
|
List<MediaDevice> _videoInputs = [];
|
||
|
|
||
|
bool _enableAudio = true;
|
||
|
bool _enableVideo = false;
|
||
|
LocalAudioTrack? _audioTrack;
|
||
|
LocalVideoTrack? _videoTrack;
|
||
|
MediaDevice? _videoDevice;
|
||
|
MediaDevice? _audioDevice;
|
||
|
|
||
|
late Room _room;
|
||
|
late EventsListener<RoomEvent> _listener;
|
||
|
|
||
|
List<ParticipantTrack> _participantTracks = [];
|
||
|
ParticipantTrack? _focusTrack;
|
||
|
|
||
|
// Getters for private fields
|
||
|
SnChatCall? get current => _current;
|
||
|
SnChannel? get channel => _channel;
|
||
|
bool get isReady => _isReady;
|
||
|
bool get isMounted => _isMounted;
|
||
|
bool get isInitialized => _isInitialized;
|
||
|
bool get isBusy => _isBusy;
|
||
|
String get lastDuration => _lastDuration;
|
||
|
List<MediaDevice> get audioInputs => _audioInputs;
|
||
|
List<MediaDevice> get videoInputs => _videoInputs;
|
||
|
bool get enableAudio => _enableAudio;
|
||
|
bool get enableVideo => _enableVideo;
|
||
|
LocalAudioTrack? get audioTrack => _audioTrack;
|
||
|
LocalVideoTrack? get videoTrack => _videoTrack;
|
||
|
MediaDevice? get videoDevice => _videoDevice;
|
||
|
MediaDevice? get audioDevice => _audioDevice;
|
||
|
List<ParticipantTrack> get participantTracks => _participantTracks;
|
||
|
ParticipantTrack? get focusTrack => _focusTrack;
|
||
|
Room get room => _room;
|
||
|
|
||
|
void _updateDuration() {
|
||
|
if (_current == null) {
|
||
|
_lastDuration = '00:00:00';
|
||
|
} else {
|
||
|
Duration duration = DateTime.now().difference(_current!.createdAt);
|
||
|
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||
|
_lastDuration = '${twoDigits(duration.inHours)}:'
|
||
|
'${twoDigits(duration.inMinutes.remainder(60))}:'
|
||
|
'${twoDigits(duration.inSeconds.remainder(60))}';
|
||
|
}
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void enableDurationUpdater() {
|
||
|
_updateDuration();
|
||
|
_lastDurationUpdateTimer = Timer.periodic(
|
||
|
const Duration(seconds: 1),
|
||
|
(_) => _updateDuration(),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
void disableDurationUpdater() {
|
||
|
_lastDurationUpdateTimer?.cancel();
|
||
|
_lastDurationUpdateTimer = null;
|
||
|
}
|
||
|
|
||
|
Future<void> 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(SnChatCall call, SnChannel related) {
|
||
|
_current = call;
|
||
|
_channel = related;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
Future<(String, String)> getRoomToken() async {
|
||
|
final resp = await _sn.client.post(
|
||
|
'/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
|
||
|
);
|
||
|
token = resp.data['token'];
|
||
|
endpoint = 'wss://${resp.data['endpoint']}';
|
||
|
return (token!, endpoint!);
|
||
|
}
|
||
|
|
||
|
void initHardware() {
|
||
|
if (_isReady) return;
|
||
|
|
||
|
_isReady = true;
|
||
|
hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
|
||
|
_revertDevices,
|
||
|
);
|
||
|
Hardware.instance.enumerateDevices().then(_revertDevices);
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
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();
|
||
|
}
|
||
|
|
||
|
Future<void> joinRoom(String url, String token) async {
|
||
|
if (_isMounted) return;
|
||
|
|
||
|
try {
|
||
|
await _room.connect(
|
||
|
url,
|
||
|
token,
|
||
|
fastConnectOptions: FastConnectOptions(
|
||
|
microphone: TrackOption(track: _audioTrack),
|
||
|
camera: TrackOption(track: _videoTrack),
|
||
|
),
|
||
|
);
|
||
|
} finally {
|
||
|
_isMounted = true;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
void setupRoom() {
|
||
|
if (isInitialized) return;
|
||
|
|
||
|
sortParticipants();
|
||
|
_room.addListener(_onRoomDidUpdate);
|
||
|
WidgetsBindingCompatible.instance?.addPostFrameCallback(
|
||
|
(_) => autoPublish(),
|
||
|
);
|
||
|
|
||
|
if (lkPlatformIsMobile()) {
|
||
|
Hardware.instance.setSpeakerphoneOn(true);
|
||
|
}
|
||
|
|
||
|
_isBusy = false;
|
||
|
_isInitialized = true;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void autoPublish() async {
|
||
|
try {
|
||
|
if (enableVideo) {
|
||
|
await _room.localParticipant?.setCameraEnabled(true);
|
||
|
}
|
||
|
if (enableAudio) {
|
||
|
await _room.localParticipant?.setMicrophoneEnabled(true);
|
||
|
}
|
||
|
} catch (error) {
|
||
|
rethrow;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Future<void> setEnableAudio(bool value) async {
|
||
|
_enableAudio = value;
|
||
|
if (!_enableAudio) {
|
||
|
await _audioTrack?.stop();
|
||
|
_audioTrack = null;
|
||
|
} else {
|
||
|
await _changeLocalAudioTrack();
|
||
|
}
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
Future<void> setEnableVideo(bool value) async {
|
||
|
_enableVideo = value;
|
||
|
if (!_enableVideo) {
|
||
|
await _videoTrack?.stop();
|
||
|
_videoTrack = null;
|
||
|
} else {
|
||
|
await _changeLocalVideoTrack();
|
||
|
}
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void setupRoomListeners({
|
||
|
required Function(DisconnectReason?) onDisconnected,
|
||
|
}) {
|
||
|
_listener
|
||
|
..on<RoomDisconnectedEvent>((event) async {
|
||
|
onDisconnected(event.reason);
|
||
|
})
|
||
|
..on<ParticipantEvent>((event) => sortParticipants())
|
||
|
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
|
||
|
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
|
||
|
..on<TrackSubscribedEvent>((_) => sortParticipants())
|
||
|
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
|
||
|
..on<ParticipantNameUpdatedEvent>((event) {
|
||
|
sortParticipants();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
void sortParticipants() {
|
||
|
Map<String, ParticipantTrack> 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<ParticipantTrack>.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 = newTracks;
|
||
|
|
||
|
if (focusTrack != null) {
|
||
|
final idx = participantTracks
|
||
|
.indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
|
||
|
if (idx == -1) {
|
||
|
_focusTrack = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (focusTrack == null) {
|
||
|
_focusTrack = participantTracks.firstOrNull;
|
||
|
} else {
|
||
|
final idx = participantTracks.indexWhere(
|
||
|
(x) => _focusTrack!.participant.sid == x.participant.sid,
|
||
|
);
|
||
|
if (idx > -1) {
|
||
|
_focusTrack = participantTracks[idx];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
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();
|
||
|
}
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
Future<void> _changeLocalVideoTrack() async {
|
||
|
if (_videoTrack != null) {
|
||
|
await _videoTrack!.stop();
|
||
|
_videoTrack = null;
|
||
|
}
|
||
|
|
||
|
if (_videoDevice != null) {
|
||
|
_videoTrack = await LocalVideoTrack.createCameraTrack(
|
||
|
CameraCaptureOptions(
|
||
|
deviceId: _videoDevice!.deviceId,
|
||
|
params: VideoParametersPresets.h1080_169,
|
||
|
),
|
||
|
);
|
||
|
await _videoTrack!.start();
|
||
|
}
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void _revertDevices(List<MediaDevice> devices) {
|
||
|
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
|
||
|
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void _onRoomDidUpdate() => sortParticipants();
|
||
|
|
||
|
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: VideoParametersPresets.h1080_169,
|
||
|
),
|
||
|
);
|
||
|
await videoTrack!.start();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
void deactivateHardware() {
|
||
|
hwSubscription?.cancel();
|
||
|
}
|
||
|
|
||
|
void disposeRoom() {
|
||
|
_isBusy = false;
|
||
|
_isMounted = false;
|
||
|
_isInitialized = false;
|
||
|
_current = null;
|
||
|
_channel = null;
|
||
|
_room.removeListener(_onRoomDidUpdate);
|
||
|
_room.disconnect();
|
||
|
_room.dispose();
|
||
|
_listener.dispose();
|
||
|
WakelockPlus.disable();
|
||
|
}
|
||
|
|
||
|
void disposeHardware() {
|
||
|
_isReady = false;
|
||
|
_audioTrack?.stop();
|
||
|
_audioTrack = null;
|
||
|
_videoTrack?.stop();
|
||
|
_videoTrack = null;
|
||
|
}
|
||
|
|
||
|
void setVideoDevice(MediaDevice? value) {
|
||
|
_videoDevice = value;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void setAudioDevice(MediaDevice? value) {
|
||
|
_audioDevice = value;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void setFocusTrack(ParticipantTrack? value) {
|
||
|
_focusTrack = value;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
|
||
|
void setIsBusy(bool value) {
|
||
|
_isBusy = value;
|
||
|
notifyListeners();
|
||
|
}
|
||
|
}
|