Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,537 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/call/call_tile.dart';
import 'package:tatchat/pages/call/call_view_model.dart';
import 'package:tatchat/pages/call/start_time.dart';
import 'package:tatchat/pages/call/utils/get_call_tiles.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/view_model_builder.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_ui/material_ui.dart';
class CallPage extends StatelessWidget {
final String roomId;
const CallPage({super.key, required this.roomId});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final room = Matrix.of(context).client.getRoomById(roomId)!;
final mini = Matrix.of(context).callPosition.value != .fullScreen;
final tileSize = FluffyThemes.isColumnMode(context) ? 256.0 : 128.0;
return ViewModelBuilder(
create: () => CallViewModel(room: room),
builder: (context, viewModel, _) {
final liveKitRoom = viewModel.value.room;
final localVideoTrack = viewModel.value.localVideoTrack;
final localParticipant = liveKitRoom?.localParticipant;
final activeMembers = room.getActiveMatrixRtcMembers().length;
final ownUser = room.unsafeGetUserFromMemoryOrFallback(
room.client.userID!,
);
final ownHandRaised = viewModel.participantRaisedHand(ownUser.id);
final userIsScreensharing =
localParticipant?.isScreenShareEnabled() == true;
final iconButtonStyle = IconButton.styleFrom(
backgroundColor: theme.colorScheme.surfaceBright.withAlpha(230),
disabledBackgroundColor: theme.colorScheme.surfaceBright.withAlpha(
230,
),
iconSize: mini ? null : 28,
padding: EdgeInsets.all(mini ? 8.0 : 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
side: BorderSide(color: theme.colorScheme.surfaceContainerHighest),
),
);
final iconButtonStyleActive = IconButton.styleFrom(
backgroundColor: theme.colorScheme.inverseSurface,
disabledBackgroundColor: theme.colorScheme.inverseSurface,
foregroundColor: theme.colorScheme.onInverseSurface,
disabledForegroundColor: theme.colorScheme.onInverseSurface,
).merge(iconButtonStyle);
return MediaQuery.removePadding(
context: context,
removeBottom: mini,
removeTop: mini,
child: ScaffoldMessenger(
child: Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: viewModel.startTime == null
? null
: StartTime(startTime: viewModel.startTime!),
backgroundColor: theme.colorScheme.surface.withAlpha(64),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.fullscreen_outlined),
onPressed: () {
Matrix.of(context).callPosition.value = mini
? .fullScreen
: .top;
},
),
if (localVideoTrack == null)
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
onTap: () => viewModel.selectCamera(context),
child: Row(
mainAxisSize: .min,
spacing: 12,
children: [
const Icon(Icons.camera_outlined),
Text(L10n.of(context).selectCamera),
],
),
),
PopupMenuItem(
onTap: () => viewModel.selectMicrophone(context),
child: Row(
mainAxisSize: .min,
spacing: 12,
children: [
const Icon(Icons.mic_outlined),
Text(L10n.of(context).selectMicrophone),
],
),
),
PopupMenuItem(
onTap: () => viewModel.selectSpeaker(context),
child: Row(
mainAxisSize: .min,
spacing: 12,
children: [
const Icon(Icons.speaker_phone_outlined),
Text(L10n.of(context).selectSpeaker),
],
),
),
],
),
],
titleSpacing: mini ? 0 : null,
title: Column(
mainAxisSize: .min,
children: [
Text(
room.getLocalizedDisplayname(),
maxLines: 1,
overflow: .ellipsis,
style: mini
? TextStyle(fontSize: 11, fontWeight: .bold)
: null,
),
if (activeMembers > 0)
Text(
viewModel.waitForOtherSide
? L10n.of(context).waitingForParticipant
: L10n.of(
context,
).countActiveCallMembers(activeMembers),
maxLines: 1,
style: TextStyle(fontSize: 11),
),
],
),
),
body: liveKitRoom == null
? Center(child: CircularProgressIndicator())
: localVideoTrack != null
? Stack(
children: [
localVideoTrack.muted
? Center(
child: Avatar(
mxContent: ownUser.avatarUrl,
name: ownUser.calcDisplayname(),
size: 128,
),
)
: VideoTrackRenderer(localVideoTrack, fit: .cover),
Align(
alignment: .topCenter,
child: SafeArea(
child: Container(
decoration: BoxDecoration(
color: viewModel.value.error == null
? theme.colorScheme.errorContainer
.withAlpha(230)
: theme.colorScheme.error.withAlpha(230),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
margin: EdgeInsets.all(16.0),
padding: EdgeInsets.all(16.0),
child: Text(
viewModel.value.error?.toLocalizedString(
context,
) ??
L10n.of(context).videoCallsBetaWarning,
textAlign: .center,
style: TextStyle(
fontSize: 11,
color: viewModel.value.error == null
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onError,
),
),
),
),
),
],
)
: SafeArea(
top: !mini,
child: LayoutBuilder(
builder: (context, constraints) {
final axis =
constraints.maxWidth < constraints.maxHeight
? Axis.vertical
: Axis.horizontal;
final tiles = liveKitRoom.getCallTiles(room);
final focused =
(viewModel.value.focusedTrack == null
? null
: tiles.firstWhereOrNull(
(tile) =>
tile.id ==
viewModel.value.focusedTrack,
)) ??
tiles.firstWhereOrNull(
(tile) => tile.video?.isScreenShare == true,
) ??
(tiles.length < 3 ? tiles.first : null);
if (mini) {
final miniFocused =
focused ??
tiles.firstOrNull ??
(
id: '${ownUser.id}_fallback_none',
user: ownUser,
video: null,
audio: null,
connected: false,
isSpeaking: false,
);
return SizedBox.expand(
child: CallTile(
key: ValueKey(miniFocused.id),
user: miniFocused.user,
video: miniFocused.video,
audio: miniFocused.audio,
connected: miniFocused.connected,
margin: EdgeInsets.zero,
handRaised: viewModel.participantRaisedHand(
miniFocused.user.id,
),
isSpeaking: miniFocused.isSpeaking,
onTap: null,
),
);
}
tiles.remove(focused);
return focused == null
? Padding(
padding: EdgeInsets.only(
bottom: 84.0,
left: 16.0,
right: 16.0,
top: 16.0,
),
child: LayoutBuilder(
builder: (context, constraints) {
final rows = sqrt(tiles.length).ceil();
final columns = (tiles.length / rows)
.ceil();
final width =
(constraints.maxWidth / rows) -
(rows * 8);
final height =
(constraints.maxHeight / columns) -
(columns * 8);
return Center(
child: Wrap(
spacing: 16.0,
runSpacing: 16.0,
alignment: .center,
runAlignment: .center,
crossAxisAlignment: .center,
children: tiles
.map(
(tile) => CallTile(
key: ValueKey(tile.id),
user: tile.user,
video: tile.video,
audio: tile.audio,
connected: tile.connected,
width: width,
height: height,
isSpeaking: tile.isSpeaking,
handRaised: viewModel
.participantRaisedHand(
tile.user.id,
),
onTap: () => viewModel
.setFocusedTrack(tile.id),
),
)
.toList(),
),
);
},
),
)
: Padding(
padding: EdgeInsets.only(
bottom: 96.0,
left: 16.0,
right: 16.0,
top: 16.0,
),
child: Flex(
direction: axis,
crossAxisAlignment: .stretch,
children: [
Expanded(
child: CallTile(
key: ValueKey(focused.id),
user: focused.user,
video: focused.video,
audio: focused.audio,
connected: focused.connected,
fit: .contain,
isSpeaking: focused.isSpeaking,
handRaised: viewModel
.participantRaisedHand(
focused.user.id,
),
onTap: () => viewModel
.setFocusedTrack(focused.id),
),
),
if (tiles.isNotEmpty) ...[
SizedBox(width: 16, height: 16),
SizedBox(
height: axis == .horizontal
? null
: tileSize,
width: axis == .vertical
? null
: tileSize,
child: ListView.builder(
scrollDirection: axis == .horizontal
? .vertical
: .horizontal,
itemCount: tiles.length,
itemBuilder: (context, i) =>
CallTile(
key: ValueKey(tiles[i].id),
user: tiles[i].user,
video: tiles[i].video,
audio: tiles[i].audio,
connected: tiles[i].connected,
width: tileSize,
height: tileSize,
isSpeaking:
tiles[i].isSpeaking,
handRaised: viewModel
.participantRaisedHand(
tiles[i].user.id,
),
onTap: () =>
viewModel.setFocusedTrack(
tiles[i].id,
),
margin: EdgeInsets.only(
right: axis == .vertical
? 16
: 0,
bottom: axis == .horizontal
? 16
: 0,
),
),
),
),
],
],
),
);
},
),
),
floatingActionButtonLocation: .centerFloat,
floatingActionButton: Wrap(
alignment: .center,
spacing: mini ? 8 : 16,
children: [
if (localParticipant != null) ...[
IconButton(
tooltip: L10n.of(context).toggleMicrophone,
style: iconButtonStyle,
onPressed: () => localParticipant.setMicrophoneEnabled(
!localParticipant.isMicrophoneEnabled(),
),
icon: Icon(
localParticipant.isMicrophoneEnabled()
? Icons.mic_outlined
: Icons.mic_off_outlined,
),
),
IconButton(
tooltip: L10n.of(context).toggleCamera,
style: iconButtonStyle,
onPressed: () => localParticipant.setCameraEnabled(
!localParticipant.isCameraEnabled(),
),
icon: Icon(
localParticipant.isCameraEnabled()
? Icons.videocam_outlined
: Icons.videocam_off_outlined,
),
),
if (!PlatformInfos.isMobile) // TODO: Fix on mobile?
IconButton(
tooltip: userIsScreensharing
? L10n.of(context).stopScreensharing
: L10n.of(context).startScreensharing,
style: userIsScreensharing
? iconButtonStyleActive
: iconButtonStyle,
onPressed: () => localParticipant.setScreenShareEnabled(
!userIsScreensharing,
),
icon: Icon(
userIsScreensharing
? Icons.stop_screen_share
: Icons.screen_share_outlined,
),
),
IconButton(
tooltip: ownHandRaised
? L10n.of(context).stopRaiseHand
: L10n.of(context).raiseHand,
style: ownHandRaised
? iconButtonStyleActive
: iconButtonStyle,
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => ownHandRaised
? room.lowerHandInMatrixRtcCall(viewModel.timeline!)
: room.raiseHandInMatrixRtcCall(),
),
icon: Icon(
ownHandRaised
? Icons.front_hand
: Icons.front_hand_outlined,
),
),
] else ...[
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: 212,
child: TextButton.icon(
onPressed: viewModel.value.isLoading
? null
: viewModel.connect,
style: ButtonStyle(
padding: iconButtonStyleActive.padding,
foregroundColor:
iconButtonStyleActive.foregroundColor,
backgroundColor:
iconButtonStyleActive.backgroundColor,
),
icon: viewModel.value.isLoading
? SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(
color: theme.colorScheme.onInverseSurface,
strokeWidth: 2,
),
)
: Icon(Icons.call_outlined),
label: Text(
viewModel.value.isLoading
? L10n.of(context).loadingPleaseWait
: room.hasActiveMatrixRtcCall
? L10n.of(context).enterCall
: L10n.of(context).startCall,
),
),
),
),
),
IconButton(
tooltip: L10n.of(context).toggleMicrophone,
style: iconButtonStyle,
onPressed: viewModel.value.isLoading
? null
: viewModel.togglePreviewMic,
icon: Icon(
(viewModel.value.startWithAudio)
? Icons.mic_outlined
: Icons.mic_off_outlined,
),
),
IconButton(
tooltip: L10n.of(context).toggleCamera,
style: iconButtonStyle,
onPressed: viewModel.value.isLoading
? null
: localVideoTrack != null
? viewModel.togglePreviewCamera
: null,
icon: Icon(
!(localVideoTrack?.muted ?? true)
? Icons.videocam_outlined
: Icons.videocam_off_outlined,
),
),
],
IconButton(
tooltip: L10n.of(context).hangUp,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
).merge(iconButtonStyle),
onPressed: () => viewModel.close(context),
icon: Icon(Icons.call_end_outlined),
),
],
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class CallTile extends StatelessWidget {
final lk.TrackPublication<lk.VideoTrack>? video;
final lk.TrackPublication<lk.AudioTrack>? audio;
final User user;
final EdgeInsets? margin;
final VoidCallback? onTap;
final lk.VideoViewFit fit;
final double width, height;
final bool handRaised, connected, isSpeaking;
const CallTile({
super.key,
this.video,
this.audio,
this.width = 256.0,
this.height = 256.0,
required this.user,
required this.onTap,
this.handRaised = false,
this.fit = lk.VideoViewFit.cover,
this.margin,
required this.connected,
required this.isSpeaking,
});
@override
Widget build(BuildContext context) {
final video = this.video?.track;
final theme = Theme.of(context);
final borderWidth = isSpeaking ? 5.0 : 1.0;
return SizedBox(
width: width,
height: height,
child: AnimatedContainer(
margin: margin,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: BoxBorder.all(
color: isSpeaking
? theme.colorScheme.primaryFixedDim
: theme.colorScheme.surfaceContainerHighest,
width: borderWidth,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Stack(
children: [
video != null && !video.muted
? lk.VideoTrackRenderer(video, fit: fit)
: Center(
child: Avatar(
size: 64,
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
),
),
if (handRaised)
Positioned(
top: 8,
right: 8,
child: Material(
borderRadius: BorderRadius.circular(32),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.front_hand_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
Positioned(
bottom: 8,
left: 8,
child: Material(
elevation: theme.appBarTheme.elevation ?? 4,
shadowColor: theme.appBarTheme.shadowColor,
borderRadius: BorderRadius.circular(7),
color: Theme.of(
context,
).colorScheme.surfaceBright.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,
),
child: Row(
mainAxisSize: .min,
spacing: 4,
children: [
if (audio != null)
Icon(
audio?.muted == false ? Icons.mic : Icons.mic_off,
size: 12,
),
if (this.video?.isScreenShare == true)
Icon(Icons.screen_share_outlined, size: 12),
Text(
user.calcDisplayname(),
maxLines: 1,
style: TextStyle(fontSize: 12),
),
if (!connected)
Icon(Icons.portable_wifi_off_outlined, size: 12),
],
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,621 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/call_kit_params.dart';
import 'package:tatchat/utils/error_reporter.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/call_keys_event_content.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/position_from_build_context.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:just_audio/just_audio.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class CallViewModelState {
lk.Room? room;
lk.LocalVideoTrack? localVideoTrack;
bool startWithAudio = true;
bool isLoading = false;
Object? error;
String? focusedTrack;
}
class CallViewModel extends ValueNotifier<CallViewModelState> {
final Room room;
Timeline? timeline;
final AudioPlayer _audioPlayer = AudioPlayer();
DateTime? startTime;
StreamSubscription? _onCallEncryptionKeysSub, _onCallMembersChanged;
Timer? _resendCallMemberState;
bool waitForOtherSide = false;
String? callKitId;
Set<String> _lastSharedParticipants = {};
DateTime? _keyCreatedAt;
Uint8List? _lastKey;
lk.BaseKeyProvider? _keyProvider;
CallViewModel({required this.room}) : super(CallViewModelState()) {
_init();
}
void setFocusedTrack(String trackId) {
Logs().d('Set focus on', trackId);
if (value.focusedTrack == trackId) {
value.focusedTrack = null;
} else {
value.focusedTrack = trackId;
}
notifyListeners();
}
Future<void> _onCallEncryptionKeys(CallKeysEvent event) async {
final callKeys = event.callKeysContent;
final keyProvider = _keyProvider;
if (keyProvider == null) {
ErrorReporter(
null,
'Received a new key but the keyProvider is not ready yet!',
).onErrorCallback(Exception(), StackTrace.current);
return;
}
await keyProvider.setRawKey(
base64Decode(callKeys.keys.key),
participantId: '${event.sender}:${callKeys.member.claimedDeviceId}',
keyIndex: callKeys.keys.index,
);
}
Future<void> _createKeyAndShare() async {
final liveKitRoom = value.room;
if (liveKitRoom == null) return;
final ownMemberId = '${room.client.userID}:${room.client.deviceID}';
final currentParticipants =
room
.getActiveMatrixRtcMembers()
.map((member) => member.membershipId)
.whereType<String>()
.toSet()
..remove(ownMemberId);
if (setEquals(currentParticipants, _lastSharedParticipants) &&
currentParticipants.isNotEmpty) {
Logs().d(
'Participant list has not changed. No need to share keys again!',
);
return;
}
var index = liveKitRoom.roomOptions.encryption!.keyProvider.getLatestIndex(
ownMemberId,
);
final keyCreatedAt = _keyCreatedAt;
final canJustForwardToNewUsers =
keyCreatedAt != null &&
DateTime.now().difference(keyCreatedAt).inSeconds < 15 &&
_lastSharedParticipants.difference(currentParticipants).isEmpty;
late final Uint8List key;
if (_lastKey == null || !canJustForwardToNewUsers) {
// Key generation
final rng = Random.secure();
key = Uint8List(16);
key.setAll(0, Iterable.generate(key.length, (i) => rng.nextInt(256)));
if (_lastKey != null) index = (index + 1) % 256;
await liveKitRoom.roomOptions.encryption!.keyProvider.setRawKey(
key,
keyIndex: index,
participantId: ownMemberId,
);
_keyCreatedAt = DateTime.now();
_lastKey = key;
if (_lastKey != null) {
await liveKitRoom.e2eeManager?.setKeyIndex(
index,
participantIdentity: ownMemberId,
);
}
} else {
key = _lastKey!;
}
final forwardParticipants = canJustForwardToNewUsers
? currentParticipants.difference(_lastSharedParticipants)
: currentParticipants;
final deviceKeys = <DeviceKeys>[];
for (final membershipId in forwardParticipants) {
final membershipParts = membershipId.split(':');
final deviceId = membershipParts.removeLast();
final userId = membershipParts.join(':');
final keys = room.client.userDeviceKeys[userId]?.deviceKeys[deviceId];
if (keys == null) {
Logs().w('No device keys found for $membershipId');
continue;
}
deviceKeys.add(keys);
}
_lastSharedParticipants = currentParticipants;
await room.shareMatrixRtcCallKey(
key: key,
index: index,
memberId: ownMemberId,
deviceKeys: deviceKeys,
);
}
Future<void> _init() async {
_onCallEncryptionKeysSub = room.client.onCallEncryptionKeys.listen(
_onCallEncryptionKeys,
);
await WakelockPlus.enable();
await lk.LiveKitClient.initialize();
// kHKDF matches the key derivation used by element-web's LiveKit JS SDK
final keyProviderOptions = rtc.KeyProviderOptions(
sharedKey: false,
ratchetSalt: Uint8List.fromList('LKFrameEncryptionKey'.codeUnits),
ratchetWindowSize: 0,
discardFrameWhenCryptorNotReady: true,
keyDerivationAlgorithm: rtc.KeyDerivationAlgorithm.kHKDF,
keyRingSize: 255,
);
final nativeKeyProvider = await rtc.frameCryptorFactory
.createDefaultKeyProvider(keyProviderOptions);
final baseKeyProvider = _keyProvider = lk.BaseKeyProvider(
nativeKeyProvider,
keyProviderOptions,
);
final liveKitRoom = value.room = lk.Room(
roomOptions: lk.RoomOptions(
adaptiveStream: true,
dynacast: true,
defaultAudioCaptureOptions: lk.AudioCaptureOptions(
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
highPassFilter: true,
),
defaultScreenShareCaptureOptions: lk.ScreenShareCaptureOptions(
useiOSBroadcastExtension: PlatformInfos.isIOS,
),
defaultAudioOutputOptions: lk.AudioOutputOptions(
speakerOn: PlatformInfos.isMobile,
),
encryption: lk.E2EEOptions(keyProvider: baseKeyProvider),
),
);
liveKitRoom.events.on<lk.ParticipantConnectedEvent>((event) async {
if (event.participant.identity ==
liveKitRoom.localParticipant?.identity) {
return;
}
if (waitForOtherSide) {
waitForOtherSide = false;
await _audioPlayer.stop();
final callKitId = this.callKitId;
if (callKitId != null) {
await FlutterCallkitIncoming.setCallConnected(callKitId);
}
}
_playJoinSound();
});
liveKitRoom.events.on<lk.ParticipantDisconnectedEvent>(_playLeaveSound);
liveKitRoom.events.on<lk.TrackE2EEStateEvent>((event) {
if (event.state == .kOk || event.state == .kNew) {
Logs().i(
'E2EE event from ${event.participant} (${event.publication})',
event.state,
);
return;
}
if (event.state == .kMissingKey) {
Logs().d('Waiting for call key...', event);
return;
}
Logs().e('LiveKit E2EE Error', event);
});
liveKitRoom.addListener(notifyListeners);
if (room.isDirectChat && room.hasActiveMatrixRtcCall) {
await connect();
} else {
value.localVideoTrack = await lk.LocalVideoTrack.createCameraTrack();
if (room.getActiveMatrixRtcMembers().firstOrNull?.callIntent == .voice) {
value.localVideoTrack?.mute();
}
value.localVideoTrack?.addListener(notifyListeners);
notifyListeners();
}
}
Future<void> togglePreviewCamera() async {
final track = value.localVideoTrack;
if (track == null) return;
if (!track.muted) {
await track.mute();
return;
}
track.removeListener(notifyListeners);
await track.stop();
value.localVideoTrack = await lk.LocalVideoTrack.createCameraTrack();
value.localVideoTrack?.addListener(notifyListeners);
notifyListeners();
}
void togglePreviewMic() {
value.startWithAudio = !value.startWithAudio;
notifyListeners();
}
bool participantRaisedHand(String matrixId) {
final timeline = this.timeline;
if (timeline == null) return false;
return room.participantRaisedHandInMatrixRtcCall(matrixId, timeline);
}
Future<void> _onNewTimelineEvent(int i) async {
final event = timeline?.events[i];
if (event == null) return;
switch (event.type) {
case 'io.element.call.reaction':
Logs().d(
'Call reactions are not yet handled',
event.content.tryGet<String>('emoji'),
);
// TODO: handle reaction
break;
case EventTypes.Reaction:
final reactedEvent = await timeline!.getEventById(
event.relationshipEventId!,
);
if (reactedEvent != null &&
reactedEvent.type == MatrixRtcCallMember.eventType &&
event.senderId == reactedEvent.senderId &&
event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key') ==
'🖐️') {
Logs().d('User raised hand!');
_playRaiseHandSound();
notifyListeners();
}
break;
case EventTypes.Redaction:
notifyListeners();
break;
}
}
Future<void> connect() async {
try {
value.isLoading = true;
notifyListeners();
final playWaitingSound =
!room.hasActiveMatrixRtcCall && room.isDirectChat;
final intent =
room.getActiveMatrixRtcMembers().firstOrNull?.callIntent ??
(value.localVideoTrack?.muted == false ? .video : .voice);
if (PlatformInfos.isMobile) {
await lk.AudioManager.instance.setSpeakerOutputPreferred(
intent == .video,
);
}
timeline = await room.getTimeline(onInsert: _onNewTimelineEvent);
final credentials = await room.joinMatrixRtcCall(intent: intent);
_onCallMembersChanged = room.client.onSync.stream
.where(
(syncUpdate) =>
syncUpdate.rooms?.join?[room.id]?.timeline?.events?.any(
(event) => event.type == MatrixRtcCallMember.eventType,
) ??
false,
)
.listen((_) {
notifyListeners();
_createKeyAndShare();
});
await _createKeyAndShare();
final startWithVideo =
!(value.localVideoTrack?.muted ?? intent == .voice);
final startWithAudio = value.startWithAudio;
await value.localVideoTrack?.stop();
await value.localVideoTrack?.dispose();
value.localVideoTrack = null;
await value.room!.connect(
credentials.url,
credentials.jwt,
fastConnectOptions: lk.FastConnectOptions(
microphone: lk.TrackOption(
track: startWithAudio ? await lk.LocalAudioTrack.create() : null,
),
camera: lk.TrackOption(
track: startWithVideo
? await lk.LocalVideoTrack.createCameraTrack()
: null,
),
),
);
_resendCallMemberState?.cancel();
_resendCallMemberState = Timer.periodic(const Duration(hours: 1), (_) {
final ownMembership = room.ownMatrixRtcMembership;
if (ownMembership == null) {
_resendCallMemberState?.cancel();
return;
}
room.setMatrixRtcMembershipState(
ownMembership.fociPreferred,
intent: intent,
);
});
startTime = DateTime.now();
if (PlatformInfos.isMobile) {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
callKitId = activeCalls
.firstWhereOrNull(
(call) =>
call.extra?['roomId'] == room.id &&
call.extra?['clientName'] == room.client.clientName,
)
?.id;
if (callKitId == null) {
final params = await buildFluffyChatCallKitParams(
room,
intent: intent,
);
await FlutterCallkitIncoming.startCall(params);
callKitId = params.id;
}
}
if (playWaitingSound) {
waitForOtherSide = true;
_playWaitingSound();
} else {
final callKitId = this.callKitId;
if (callKitId != null) {
await FlutterCallkitIncoming.setCallConnected(callKitId);
}
_playJoinSound();
}
notifyListeners();
} catch (e, s) {
Logs().e('Error while connecting', e, s);
value.error = e;
ErrorReporter(null, 'Unable to connect').onErrorCallback(e, s);
} finally {
value.isLoading = false;
notifyListeners();
}
}
Future<void> close(BuildContext context) async {
_playLeaveSound();
final liveKitRoom = value.room;
if (liveKitRoom != null) {
liveKitRoom.removeListener(notifyListeners);
await showFutureLoadingDialog(
context: context,
future: () async {
if (liveKitRoom.connectionState != lk.ConnectionState.disconnected) {
await liveKitRoom.disconnect();
}
await liveKitRoom.dispose();
value.room = null;
if (room.ownMatrixRtcMembership != null) {
await room.leaveMatrixRtcCall();
}
},
);
}
if (context.mounted) {
Matrix.of(context).activeCallRoomId.value = null;
}
}
@override
void dispose() {
_onCallEncryptionKeysSub?.cancel();
_onCallMembersChanged?.cancel();
_resendCallMemberState?.cancel();
timeline?.cancelSubscriptions();
value.localVideoTrack?.dispose();
final callKitId = this.callKitId;
if (callKitId != null) {
FlutterCallkitIncoming.endCall(callKitId);
}
final liveKitRoom = value.room;
if (liveKitRoom != null) {
liveKitRoom.removeListener(notifyListeners);
if (liveKitRoom.connectionState != lk.ConnectionState.disconnected) {
liveKitRoom.disconnect();
}
liveKitRoom.dispose();
room.leaveMatrixRtcCall();
}
_audioPlayer.dispose();
WakelockPlus.disable();
super.dispose();
}
Future<void> _playSoundIndex(
AudioSource audioSource, {
LoopMode loopMode = .off,
}) async {
if (_audioPlayer.playing) await _audioPlayer.stop();
await _audioPlayer.setAudioSource(audioSource);
if (_audioPlayer.loopMode != loopMode) {
await _audioPlayer.setLoopMode(loopMode);
}
try {
await _audioPlayer.play();
} catch (e) {
Logs().w('Unable to start audio player. Try again...', e);
await Future.delayed(const Duration(milliseconds: 300));
await _audioPlayer.play();
}
}
final _joinSource = AudioSource.asset('assets/sounds/call_join.mp3');
final _leaveSource = AudioSource.asset('assets/sounds/call_leave.mp3');
final _raiseHandSource = AudioSource.asset(
'assets/sounds/call_attention.mp3',
);
final _waitSource = AudioSource.asset('assets/sounds/call_wait.mp3');
Future<void> _playJoinSound() => _playSoundIndex(_joinSource);
Future<void> _playLeaveSound([_]) => _playSoundIndex(_leaveSource);
Future<void> _playRaiseHandSound() => _playSoundIndex(_raiseHandSource);
Future<void> _playWaitingSound() =>
_playSoundIndex(_waitSource, loopMode: .all);
Future<lk.MediaDevice?> _selectMediaDevice(
BuildContext context,
List<lk.MediaDevice> devices,
String? activeDeviceId,
) async {
if (!context.mounted) return null;
return await showMenu<lk.MediaDevice>(
context: context,
position: context.position,
items: devices.isEmpty
? [
PopupMenuItem(
enabled: false,
child: Text(L10n.of(context).noDevicesFound),
),
]
: devices
.map(
(device) => PopupMenuItem(
value: device,
child: Row(
mainAxisSize: .min,
spacing: 12,
children: [
Icon(
device.deviceId == activeDeviceId
? Icons.check_circle_outlined
: Icons.circle_outlined,
),
Expanded(
child: Text(
device.label,
maxLines: 1,
overflow: .ellipsis,
),
),
],
),
),
)
.toList(),
);
}
Future<void> selectCamera(BuildContext context) async {
final devices = await lk.Hardware.instance.videoInputs();
if (!context.mounted) return;
final source = await _selectMediaDevice(
context,
devices,
value.room?.selectedVideoInputDeviceId,
);
if (source == null) return;
await value.room?.setVideoInputDevice(source);
}
Future<void> selectMicrophone(BuildContext context) async {
final devices = await lk.Hardware.instance.audioInputs();
if (!context.mounted) return;
final source = await _selectMediaDevice(
context,
devices,
value.room?.selectedAudioInputDeviceId,
);
if (source == null) return;
await value.room?.setAudioInputDevice(source);
}
Future<void> selectSpeaker(BuildContext context) async {
final devices = await lk.Hardware.instance.audioOutputs();
if (!context.mounted) return;
if (devices.isEmpty && PlatformInfos.isMobile) {
final preferSpeaker = lk.AudioManager.instance.isSpeakerOutputPreferred;
final toggle = await showMenu<bool>(
context: context,
position: context.position,
items: [
PopupMenuItem(
value: true,
child: Row(
mainAxisSize: .min,
spacing: 12,
children: [
Icon(
preferSpeaker
? Icons.check_circle_outlined
: Icons.circle_outlined,
),
Expanded(
child: Text(
L10n.of(context).preferSpeaker,
maxLines: 1,
overflow: .ellipsis,
),
),
],
),
),
],
);
if (toggle == null) return;
await lk.AudioManager.instance.setSpeakerOutputPreferred(!preferSpeaker);
return;
}
final source = await _selectMediaDevice(
context,
devices,
value.room?.selectedAudioOutputDeviceId,
);
if (source == null) return;
await value.room?.setAudioOutputDevice(source);
}
}

View File

@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:flutter/widgets.dart';
class StartTime extends StatefulWidget {
final DateTime startTime;
const StartTime({required this.startTime, super.key});
@override
State<StartTime> createState() => _StartTimeState();
}
class _StartTimeState extends State<StartTime> {
late final Timer _timer;
@override
void initState() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
super.initState();
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final duration = DateTime.now().difference(widget.startTime);
return Center(
child: Text(
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 11),
),
);
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:collection/collection.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart';
typedef TileData = ({
String id,
lk.TrackPublication<lk.VideoTrack>? video,
lk.TrackPublication<lk.AudioTrack>? audio,
User user,
bool connected,
bool isSpeaking,
});
extension GetCallTiles on lk.Room {
/// Calculates all call tiles to be rendered for this call in this matrix
/// room. For users without any camera track it adds a CallTile with a
/// null video track at least.
List<TileData> getCallTiles(Room room) {
final tiles = <TileData>[];
for (final member in room.getActiveMatrixRtcMembers()) {
final participantId = '${member.senderId}:${member.deviceId}';
// The local user is handled below:
if (localParticipant?.identity == participantId ||
(member.senderId == room.client.userID &&
member.deviceId == room.client.deviceID)) {
continue;
}
final participant =
remoteParticipants[participantId] ??
remoteParticipants[member.membershipId];
final matrixId = member.senderId ?? participant?.matrixId;
if (matrixId == null) {
Logs().wtf('No matrix ID found for this member!');
continue;
}
final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
final audio = participant?.audioTrackPublications.firstOrNull;
if (participant == null ||
!participant.videoTrackPublications.any(
(pub) => !pub.isScreenShare,
)) {
tiles.add((
id: '${participantId}_none',
user: user,
video: null,
audio: audio,
connected: participant != null,
isSpeaking: participant?.isSpeaking == true,
));
}
for (final pub
in participant?.videoTrackPublications ??
<lk.RemoteTrackPublication<lk.RemoteVideoTrack>>[]) {
tiles.add((
id: '${participantId}_${pub.name}',
user: user,
video: pub,
audio: pub.isScreenShare ? null : audio,
connected: participant != null,
isSpeaking: participant?.isSpeaking == true && !pub.isScreenShare,
));
}
}
// Handle the local participant here:
final ownUser = room.unsafeGetUserFromMemoryOrFallback(room.client.userID!);
for (final pub
in localParticipant?.videoTrackPublications ??
<lk.LocalTrackPublication<lk.LocalVideoTrack>>[]) {
tiles.add((
id: '${localParticipant?.identity ?? ownUser.id}_${pub.name}',
user: ownUser,
video: pub,
audio: localParticipant?.audioTrackPublications.firstOrNull,
connected: localParticipant != null,
isSpeaking: localParticipant?.isSpeaking == true && !pub.isScreenShare,
));
}
if (localParticipant?.videoTrackPublications.any(
(pub) => !pub.isScreenShare,
) !=
true) {
tiles.add((
id: '${localParticipant?.identity ?? ownUser.id}_none',
user: ownUser,
video: null,
audio: localParticipant?.audioTrackPublications.firstOrNull,
connected: localParticipant != null,
isSpeaking: localParticipant?.isSpeaking == true,
));
}
return tiles;
}
}
extension on lk.RemoteParticipant {
String get matrixId => (identity.split(':')..removeLast()).join(':');
}