Compare commits

..

No commits in common. "253cd1ecbdb4495f7508c669035bd853f597c771" and "433beec2dd60ff09e6cee73750bb78cfbf4710ba" have entirely different histories.

10 changed files with 189 additions and 265 deletions

View File

@ -17,10 +17,6 @@ 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;
@ -42,34 +38,6 @@ 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;
@ -151,6 +119,8 @@ 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 {
@ -164,8 +134,6 @@ class ChatCallProvider extends GetxController {
); );
} catch (e) { } catch (e) {
rethrow; rethrow;
} finally {
isMounted.value = true;
} }
} }
@ -197,7 +165,6 @@ class ChatCallProvider extends GetxController {
Hardware.instance.setSpeakerphoneOn(true); Hardware.instance.setSpeakerphoneOn(true);
} }
isBusy.value = false;
isInitialized.value = true; isInitialized.value = true;
} }

View File

@ -12,15 +12,16 @@ 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 {
final bool hideAppBar; const CallScreen({super.key});
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;
@ -36,6 +37,26 @@ 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++);
@ -170,15 +191,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void initState() { void initState() {
Get.find<ChatCallProvider>().setupRoom();
super.initState(); super.initState();
Future.delayed(Duration.zero, () { _updateDuration();
Get.find<ChatCallProvider>()
..setupRoom()
..enableDurationUpdater();
_planAutoHideControls(); _planAutoHideControls();
}); _timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
} }
@override @override
@ -189,19 +210,16 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider ctrl = Get.find(); final ChatCallProvider provider = Get.find();
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: widget.hideAppBar appBar: AppBar(
? null
: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
centerTitle: true, centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
title: Obx( title: RichText(
() => RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
@ -210,13 +228,12 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: ctrl.lastDuration.value, text: _currentDuration,
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,
@ -242,20 +259,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Obx(() { Row(
return Row(
children: [ children: [
Text( Text(call.room.serverRegion ?? 'unknown'),
call.channel.value!.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(call.lastDuration.value) Text(call.room.serverVersion ?? 'unknown')
], ],
); ),
}),
Row( Row(
children: [ children: [
Text( Text(
@ -322,6 +332,7 @@ 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) {
@ -334,15 +345,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
), ),
), ),
if (ctrl.room.localParticipant != null) if (provider.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(
ctrl.room, provider.room,
ctrl.room.localParticipant!, provider.room.localParticipant!,
), ),
), ),
), ),
@ -359,13 +370,17 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void deactivate() { void deactivate() {
Get.find<ChatCallProvider>().disableDurationUpdater(); _timer?.cancel();
_timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
Get.find<ChatCallProvider>().enableDurationUpdater(); _timer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
super.activate(); super.activate();
} }
} }

View File

@ -11,11 +11,9 @@ 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';
@ -41,7 +39,7 @@ class ChannelChatScreen extends StatefulWidget {
} }
class _ChannelChatScreenState extends State<ChannelChatScreen> class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver, TickerProviderStateMixin { with WidgetsBindingObserver {
DateTime? _isOutOfSyncSince; DateTime? _isOutOfSyncSince;
bool _isBusy = false; bool _isBusy = false;
@ -240,21 +238,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
); );
} }
return Row( return Column(
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(
@ -272,8 +261,7 @@ 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: tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
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),
@ -322,23 +310,6 @@ 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,7 +269,6 @@ 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,7 +247,6 @@ const i18nSimplifiedChinese = {
'callOngoing': '一则通话正在进行中…', 'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…', 'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…', 'callOngoingParticipants': '@count 人正在进行通话…',
'callOngoingJoined': '通话进行 @duration',
'callJoin': '加入', 'callJoin': '加入',
'callResume': '恢复', 'callResume': '恢复',
'callMicrophone': '麦克风', 'callMicrophone': '麦克风',

View File

@ -9,20 +9,14 @@ 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, {super.key, required this.channel, required this.ongoingCall});
required this.channel,
required this.ongoingCall,
required this.onJoin,
});
void _showCallPrejoin(BuildContext context) { void _showCallPrejoin(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
@ -46,72 +40,48 @@ class ChannelCallIndicator extends StatelessWidget {
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
content: Row( content: Row(
children: [ children: [
Obx(() { if (ongoingCall.participants.isEmpty) Text('callOngoingEmpty'.tr),
if (call.isInitialized.value) { if (ongoingCall.participants.isNotEmpty)
return Text('callOngoingJoined'.trParams({ Text('callOngoingParticipants'.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),
Obx(() { if (ongoingCall.participants.isNotEmpty)
if (call.isInitialized.value) { Container(
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 = final userinfo = Account.fromJson(jsonDecode(x['metadata']));
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.isBusy.value) { if (call.current.value == null) {
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: () => onJoin(), onPressed: () => call.gotoScreen(context),
child: Text('callResume'.tr), child: Text('callResume'.tr),
); );
} else if (!SolianTheme.isLargeScreen(context)) { } else {
return TextButton( return TextButton(
onPressed: null, onPressed: null,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} }
return const SizedBox();
}) })
], ],
); );

View File

@ -88,14 +88,12 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void _disconnect() async { void _disconnect() async {
if (await showDisconnectDialog() != true) return; if (await showDisconnectDialog() != true) return;
final ChatCallProvider call = Get.find(); final ChatCallProvider provider = Get.find();
if (call.current.value != null) { if (provider.current.value != null) {
call.disposeRoom(); provider.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);
@ -211,7 +209,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
runSpacing: 5, runSpacing: 5,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.exit_to_app), icon: Transform.flip(
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,23 +25,22 @@ 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 call = Get.find(); final ChatCallProvider provider = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
call.setCall(widget.ongoingCall, widget.channel); provider.setCall(widget.ongoingCall, widget.channel);
call.isBusy.value = true;
try { try {
final resp = await call.getRoomToken(); final resp = await provider.getRoomToken();
final token = resp.$1; final token = resp.$1;
final endpoint = resp.$2; final endpoint = resp.$2;
call.initRoom(); provider.initRoom();
call.setupRoomListeners( provider.setupRoomListeners(
onDisconnected: (reason) { onDisconnected: (reason) {
context.showSnackbar( context.showSnackbar(
'callDisconnected'.trParams({'reason': reason.toString()}), 'callDisconnected'.trParams({'reason': reason.toString()}),
@ -49,9 +48,11 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
}, },
); );
call.joinRoom(endpoint, token); provider.joinRoom(endpoint, token);
provider.gotoScreen(context).then((_) {
Navigator.pop(context); Navigator.pop(context);
});
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
@ -61,9 +62,9 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
@override @override
void initState() { void initState() {
final ChatCallProvider call = Get.find(); final ChatCallProvider provider = Get.find();
call.checkPermissions().then((_) { provider.checkPermissions().then((_) {
call.initHardware(); provider.initHardware();
}); });
super.initState(); super.initState();
@ -168,7 +169,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 call = Get.find(); final ChatCallProvider provider = Get.find();
return Obx(() { return Obx(() {
if (call.current.value == null || call.channel.value == null) { if (provider.current.value == null || provider.channel.value == null) {
return const SizedBox(); return const SizedBox();
} }
@ -18,8 +18,11 @@ 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(call.channel.value!.name), title: Text(provider.channel.value!.name),
subtitle: Text('callAlreadyOngoing'.tr), subtitle: Text('callAlreadyOngoing'.tr),
onTap: () {
provider.gotoScreen(context);
},
); );
}); });
} }

View File

@ -73,7 +73,7 @@ class ChatEvent extends StatelessWidget {
return Container( return Container(
key: Key('m${item.uuid}attachments-box'), key: Key('m${item.uuid}attachments-box'),
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
padding: EdgeInsets.only(top: isMerged ? 0 : 4, bottom: 4), padding: EdgeInsets.only(top: isMerged ? 0 : 4),
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxHeight: 720, maxHeight: 720,
), ),
@ -296,8 +296,8 @@ class ChatEvent extends StatelessWidget {
), ),
], ],
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
_buildLinkExpansion().paddingOnly(left: 52, right: 8), _buildLinkExpansion().paddingOnly(left: 52),
_buildAttachment(context).paddingOnly(left: 56, right: 8), _buildAttachment(context).paddingOnly(left: 56),
], ],
); );
} }