Call in same screen on large screen

This commit is contained in:
LittleSheep 2024-08-20 01:10:15 +08:00
parent c82c48dfec
commit 253cd1ecbd
10 changed files with 265 additions and 189 deletions

View File

@ -17,6 +17,10 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs; RxBool isReady = false.obs;
RxBool isMounted = false.obs; RxBool isMounted = false.obs;
RxBool isInitialized = false.obs; RxBool isInitialized = false.obs;
RxBool isBusy = false.obs;
RxString lastDuration = '00:00:00'.obs;
Timer? lastDurationUpdateTimer;
String? token; String? token;
String? endpoint; String? endpoint;
@ -38,6 +42,34 @@ class ChatCallProvider extends GetxController {
RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true); RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true);
Rx<ParticipantTrack?> focusTrack = Rx(null); Rx<ParticipantTrack?> focusTrack = Rx(null);
void _updateDuration() {
if (current.value == null) {
lastDuration.value = '00:00:00';
return;
}
Duration duration = DateTime.now().difference(current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
lastDuration.value = formattedTime;
}
void enableDurationUpdater() {
_updateDuration();
lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
lastDurationUpdateTimer?.cancel();
lastDurationUpdateTimer = null;
}
Future<void> checkPermissions() async { Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) { if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return; return;
@ -119,8 +151,6 @@ class ChatCallProvider extends GetxController {
void joinRoom(String url, String token) async { void joinRoom(String url, String token) async {
if (isMounted.value) { if (isMounted.value) {
return; return;
} else {
isMounted.value = true;
} }
try { try {
@ -134,6 +164,8 @@ class ChatCallProvider extends GetxController {
); );
} catch (e) { } catch (e) {
rethrow; rethrow;
} finally {
isMounted.value = true;
} }
} }
@ -165,6 +197,7 @@ class ChatCallProvider extends GetxController {
Hardware.instance.setSpeakerphoneOn(true); Hardware.instance.setSpeakerphoneOn(true);
} }
isBusy.value = false;
isInitialized.value = true; isInitialized.value = true;
} }

View File

@ -12,16 +12,15 @@ import 'package:solian/widgets/chat/call/call_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit; import 'package:livekit_client/livekit_client.dart' as livekit;
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
const CallScreen({super.key}); final bool hideAppBar;
const CallScreen({super.key, this.hideAppBar = false});
@override @override
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
} }
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin { class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? _timer;
String _currentDuration = '00:00:00';
int _layoutMode = 0; int _layoutMode = 0;
bool _showControls = true; bool _showControls = true;
@ -37,26 +36,6 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
String _parseDuration() {
final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00';
Duration duration =
DateTime.now().difference(provider.current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
return formattedTime;
}
void _updateDuration() {
setState(() {
_currentDuration = _parseDuration();
});
}
void _switchLayout() { void _switchLayout() {
if (_layoutMode < 1) { if (_layoutMode < 1) {
setState(() => _layoutMode++); setState(() => _layoutMode++);
@ -191,15 +170,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void initState() { void initState() {
Get.find<ChatCallProvider>().setupRoom();
super.initState(); super.initState();
_updateDuration(); Future.delayed(Duration.zero, () {
Get.find<ChatCallProvider>()
..setupRoom()
..enableDurationUpdater();
_planAutoHideControls(); _planAutoHideControls();
_timer = Timer.periodic( });
const Duration(seconds: 1),
(_) => _updateDuration(),
);
} }
@override @override
@ -210,16 +189,19 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find(); final ChatCallProvider ctrl = Get.find();
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: widget.hideAppBar
? null
: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
centerTitle: true, centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
title: RichText( title: Obx(
() => RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
@ -228,12 +210,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: _currentDuration, text: ctrl.lastDuration.value,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
]), ]),
), ),
), ),
),
body: SafeArea( body: SafeArea(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@ -259,13 +242,20 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Obx(() {
return Row(
children: [ children: [
Text(call.room.serverRegion ?? 'unknown'), Text(
const SizedBox(width: 6), call.channel.value!.name,
Text(call.room.serverVersion ?? 'unknown') style: const TextStyle(
], fontWeight: FontWeight.bold,
), ),
),
const SizedBox(width: 6),
Text(call.lastDuration.value)
],
);
}),
Row( Row(
children: [ children: [
Text( Text(
@ -332,7 +322,6 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Expanded( Expanded(
child: Material( child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
switch (_layoutMode) { switch (_layoutMode) {
@ -345,15 +334,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
), ),
), ),
if (provider.room.localParticipant != null) if (ctrl.room.localParticipant != null)
SizeTransition( SizeTransition(
sizeFactor: _controlsAnimation, sizeFactor: _controlsAnimation,
axis: Axis.vertical, axis: Axis.vertical,
child: SizedBox( child: SizedBox(
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
child: ControlsWidget( child: ControlsWidget(
provider.room, ctrl.room,
provider.room.localParticipant!, ctrl.room.localParticipant!,
), ),
), ),
), ),
@ -370,17 +359,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void deactivate() { void deactivate() {
_timer?.cancel(); Get.find<ChatCallProvider>().disableDurationUpdater();
_timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
_timer ??= Timer.periodic( Get.find<ChatCallProvider>().enableDurationUpdater();
const Duration(seconds: 1),
(_) => _updateDuration(),
);
super.activate(); super.activate();
} }
} }

View File

@ -11,9 +11,11 @@ import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/channel/call/call.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
@ -39,7 +41,7 @@ class ChannelChatScreen extends StatefulWidget {
} }
class _ChannelChatScreenState extends State<ChannelChatScreen> class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver, TickerProviderStateMixin {
DateTime? _isOutOfSyncSince; DateTime? _isOutOfSyncSince;
bool _isBusy = false; bool _isBusy = false;
@ -238,12 +240,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
); );
} }
return Column( return Row(
children: [
Expanded(
child: Column(
children: [ children: [
if (_ongoingCall != null) if (_ongoingCall != null)
ChannelCallIndicator( ChannelCallIndicator(
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall!, ongoingCall: _ongoingCall!,
onJoin: () {
if (!SolianTheme.isLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
), ),
Expanded( Expanded(
child: ChatEventList( child: ChatEventList(
@ -261,7 +272,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
if (_isOutOfSyncSince != null) if (_isOutOfSyncSince != null)
ListTile( ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8), contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainerLow, tileColor:
Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off), leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr), title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr), subtitle: Text('messageOutOfSyncCaption'.tr),
@ -310,6 +322,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
), ),
), ),
], ],
),
),
Obx(() {
final ChatCallProvider call = Get.find();
if (call.isMounted.value && SolianTheme.isLargeScreen(context)) {
return const Expanded(
child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3),
Expanded(
child: CallScreen(hideAppBar: true),
),
]),
);
}
return const SizedBox();
}),
],
); );
}), }),
); );

View File

@ -269,6 +269,7 @@ const i18nEnglish = {
'callOngoing': 'A call is ongoing...', 'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...', 'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...', 'callOngoingParticipants': '@count people are calling...',
'callOngoingJoined': 'Call last @duration',
'callJoin': 'Join', 'callJoin': 'Join',
'callResume': 'Resume', 'callResume': 'Resume',
'callMicrophone': 'Microphone', 'callMicrophone': 'Microphone',

View File

@ -247,6 +247,7 @@ const i18nSimplifiedChinese = {
'callOngoing': '一则通话正在进行中…', 'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…', 'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…', 'callOngoingParticipants': '@count 人正在进行通话…',
'callOngoingJoined': '通话进行 @duration',
'callJoin': '加入', 'callJoin': '加入',
'callResume': '恢复', 'callResume': '恢复',
'callMicrophone': '麦克风', 'callMicrophone': '麦克风',

View File

@ -9,14 +9,20 @@ import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/call.dart'; import 'package:solian/providers/call.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart'; import 'package:solian/widgets/chat/call/call_prejoin.dart';
class ChannelCallIndicator extends StatelessWidget { class ChannelCallIndicator extends StatelessWidget {
final Channel channel; final Channel channel;
final Call ongoingCall; final Call ongoingCall;
final Function onJoin;
const ChannelCallIndicator( const ChannelCallIndicator({
{super.key, required this.channel, required this.ongoingCall}); super.key,
required this.channel,
required this.ongoingCall,
required this.onJoin,
});
void _showCallPrejoin(BuildContext context) { void _showCallPrejoin(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
@ -40,48 +46,72 @@ class ChannelCallIndicator extends StatelessWidget {
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
content: Row( content: Row(
children: [ children: [
if (ongoingCall.participants.isEmpty) Text('callOngoingEmpty'.tr), Obx(() {
if (ongoingCall.participants.isNotEmpty) if (call.isInitialized.value) {
Text('callOngoingParticipants'.trParams({ return Text('callOngoingJoined'.trParams({
'duration': call.lastDuration.value,
}));
} else if (ongoingCall.participants.isEmpty) {
return Text('callOngoingEmpty'.tr);
} else {
return Text('callOngoingParticipants'.trParams({
'count': ongoingCall.participants.length.toString(), 'count': ongoingCall.participants.length.toString(),
})), }));
}
}),
const SizedBox(width: 6), const SizedBox(width: 6),
if (ongoingCall.participants.isNotEmpty) Obx(() {
Container( if (call.isInitialized.value) {
return const SizedBox();
}
if (ongoingCall.participants.isNotEmpty) {
return Container(
height: 28, height: 28,
constraints: const BoxConstraints(maxWidth: 120), constraints: const BoxConstraints(maxWidth: 120),
child: AvatarStack( child: AvatarStack(
height: 28, height: 28,
borderWidth: 0, borderWidth: 0,
avatars: ongoingCall.participants.map((x) { avatars: ongoingCall.participants.map((x) {
final userinfo = Account.fromJson(jsonDecode(x['metadata'])); final userinfo =
Account.fromJson(jsonDecode(x['metadata']));
return PlatformInfo.canCacheImage return PlatformInfo.canCacheImage
? CachedNetworkImageProvider(userinfo.avatar) ? CachedNetworkImageProvider(userinfo.avatar)
as ImageProvider as ImageProvider
: NetworkImage(userinfo.avatar) as ImageProvider; : NetworkImage(userinfo.avatar) as ImageProvider;
}).toList(), }).toList(),
), ),
), );
}
return const SizedBox();
})
], ],
), ),
actions: [ actions: [
Obx(() { Obx(() {
if (call.current.value == null) { if (call.isBusy.value) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3),
).paddingAll(16);
} else if (call.current.value == null) {
return TextButton( return TextButton(
onPressed: () => _showCallPrejoin(context), onPressed: () => _showCallPrejoin(context),
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} else if (call.channel.value?.id == channel.id) { } else if (call.channel.value?.id == channel.id &&
!SolianTheme.isLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: () => call.gotoScreen(context), onPressed: () => onJoin(),
child: Text('callResume'.tr), child: Text('callResume'.tr),
); );
} else { } else if (!SolianTheme.isLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: null, onPressed: null,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} }
return const SizedBox();
}) })
], ],
); );

View File

@ -88,12 +88,14 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void _disconnect() async { void _disconnect() async {
if (await showDisconnectDialog() != true) return; if (await showDisconnectDialog() != true) return;
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
if (provider.current.value != null) { if (call.current.value != null) {
provider.disposeRoom(); call.disposeRoom();
if (Navigator.canPop(context)) {
Navigator.pop(context); Navigator.pop(context);
} }
} }
}
void _disableAudio() async { void _disableAudio() async {
await _participant.setMicrophoneEnabled(false); await _participant.setMicrophoneEnabled(false);
@ -209,8 +211,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
runSpacing: 5, runSpacing: 5,
children: [ children: [
IconButton( IconButton(
icon: Transform.flip( icon: const Icon(Icons.exit_to_app),
flipX: true, child: const Icon(Icons.exit_to_app)),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
onPressed: _disconnect, onPressed: _disconnect,
), ),

View File

@ -25,22 +25,23 @@ class ChatCallPrejoinPopup extends StatefulWidget {
class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> { class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
bool _isBusy = false; bool _isBusy = false;
void performJoin() async { void _performJoin() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
provider.setCall(widget.ongoingCall, widget.channel); call.setCall(widget.ongoingCall, widget.channel);
call.isBusy.value = true;
try { try {
final resp = await provider.getRoomToken(); final resp = await call.getRoomToken();
final token = resp.$1; final token = resp.$1;
final endpoint = resp.$2; final endpoint = resp.$2;
provider.initRoom(); call.initRoom();
provider.setupRoomListeners( call.setupRoomListeners(
onDisconnected: (reason) { onDisconnected: (reason) {
context.showSnackbar( context.showSnackbar(
'callDisconnected'.trParams({'reason': reason.toString()}), 'callDisconnected'.trParams({'reason': reason.toString()}),
@ -48,11 +49,9 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
}, },
); );
provider.joinRoom(endpoint, token); call.joinRoom(endpoint, token);
provider.gotoScreen(context).then((_) {
Navigator.pop(context); Navigator.pop(context);
});
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
@ -62,9 +61,9 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
@override @override
void initState() { void initState() {
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
provider.checkPermissions().then((_) { call.checkPermissions().then((_) {
provider.initHardware(); call.initHardware();
}); });
super.initState(); super.initState();
@ -169,7 +168,7 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.primaryContainer, Theme.of(context).colorScheme.primaryContainer,
), ),
onPressed: _isBusy ? null : performJoin, onPressed: _isBusy ? null : _performJoin,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
), ),
], ],

View File

@ -7,10 +7,10 @@ class ChatCallCurrentIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
return Obx(() { return Obx(() {
if (provider.current.value == null || provider.channel.value == null) { if (call.current.value == null || call.channel.value == null) {
return const SizedBox(); return const SizedBox();
} }
@ -18,11 +18,8 @@ class ChatCallCurrentIndicator extends StatelessWidget {
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 32), contentPadding: const EdgeInsets.symmetric(horizontal: 32),
leading: const Icon(Icons.call), leading: const Icon(Icons.call),
title: Text(provider.channel.value!.name), title: Text(call.channel.value!.name),
subtitle: Text('callAlreadyOngoing'.tr), subtitle: Text('callAlreadyOngoing'.tr),
onTap: () {
provider.gotoScreen(context);
},
); );
}); });
} }

View File

@ -296,8 +296,8 @@ class ChatEvent extends StatelessWidget {
), ),
], ],
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
_buildLinkExpansion().paddingOnly(left: 52), _buildLinkExpansion().paddingOnly(left: 52, right: 8),
_buildAttachment(context).paddingOnly(left: 56), _buildAttachment(context).paddingOnly(left: 56, right: 8),
], ],
); );
} }