Initial commit
This commit is contained in:
537
lib/pages/call/call_page.dart
Normal file
537
lib/pages/call/call_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/pages/call/call_tile.dart
Normal file
140
lib/pages/call/call_tile.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
621
lib/pages/call/call_view_model.dart
Normal file
621
lib/pages/call/call_view_model.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
43
lib/pages/call/start_time.dart
Normal file
43
lib/pages/call/start_time.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/pages/call/utils/get_call_tiles.dart
Normal file
109
lib/pages/call/utils/get_call_tiles.dart
Normal 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(':');
|
||||
}
|
||||
Reference in New Issue
Block a user