Files
tatuchat/lib/pages/call/call_page.dart
Welton Moura 0037b0b9cf
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
Initial commit
2026-09-11 15:59:56 -03:00

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),
),
],
),
),
),
);
},
);
}
}