538 lines
25 KiB
Dart
538 lines
25 KiB
Dart
// 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|