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 isMounted = false.obs;
RxBool isInitialized = false.obs;
RxBool isBusy = false.obs;
RxString lastDuration = '00:00:00'.obs;
Timer? lastDurationUpdateTimer;
String? token;
String? endpoint;
@ -42,34 +38,6 @@ class ChatCallProvider extends GetxController {
RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true);
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 {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
@ -151,6 +119,8 @@ class ChatCallProvider extends GetxController {
void joinRoom(String url, String token) async {
if (isMounted.value) {
return;
} else {
isMounted.value = true;
}
try {
@ -164,8 +134,6 @@ class ChatCallProvider extends GetxController {
);
} catch (e) {
rethrow;
} finally {
isMounted.value = true;
}
}
@ -197,7 +165,6 @@ class ChatCallProvider extends GetxController {
Hardware.instance.setSpeakerphoneOn(true);
}
isBusy.value = false;
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;
class CallScreen extends StatefulWidget {
final bool hideAppBar;
const CallScreen({super.key, this.hideAppBar = false});
const CallScreen({super.key});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? _timer;
String _currentDuration = '00:00:00';
int _layoutMode = 0;
bool _showControls = true;
@ -36,6 +37,26 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
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() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
@ -170,15 +191,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override
void initState() {
Get.find<ChatCallProvider>().setupRoom();
super.initState();
Future.delayed(Duration.zero, () {
Get.find<ChatCallProvider>()
..setupRoom()
..enableDurationUpdater();
_planAutoHideControls();
});
_updateDuration();
_planAutoHideControls();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
@override
@ -189,34 +210,30 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final ChatCallProvider ctrl = Get.find();
final ChatCallProvider provider = Get.find();
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: widget.hideAppBar
? null
: AppBar(
leading: AppBarLeadingButton.adaptive(context),
centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context),
title: Obx(
() => RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr,
style: Theme.of(context).textTheme.titleLarge,
),
const TextSpan(text: '\n'),
TextSpan(
text: ctrl.lastDuration.value,
style: Theme.of(context).textTheme.bodySmall,
),
]),
),
),
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context),
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr,
style: Theme.of(context).textTheme.titleLarge,
),
const TextSpan(text: '\n'),
TextSpan(
text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall,
),
]),
),
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
@ -242,20 +259,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
return Row(
children: [
Text(
call.channel.value!.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),
Text(call.lastDuration.value)
],
);
}),
Row(
children: [
Text(call.room.serverRegion ?? 'unknown'),
const SizedBox(width: 6),
Text(call.room.serverVersion ?? 'unknown')
],
),
Row(
children: [
Text(
@ -322,6 +332,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder(
builder: (context) {
switch (_layoutMode) {
@ -334,15 +345,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
),
),
),
if (ctrl.room.localParticipant != null)
if (provider.room.localParticipant != null)
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
ctrl.room,
ctrl.room.localParticipant!,
provider.room,
provider.room.localParticipant!,
),
),
),
@ -359,13 +370,17 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override
void deactivate() {
Get.find<ChatCallProvider>().disableDurationUpdater();
_timer?.cancel();
_timer = null;
super.deactivate();
}
@override
void activate() {
Get.find<ChatCallProvider>().enableDurationUpdater();
_timer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
super.activate();
}
}

View File

@ -11,11 +11,9 @@ import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.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/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
@ -41,7 +39,7 @@ class ChannelChatScreen extends StatefulWidget {
}
class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver, TickerProviderStateMixin {
with WidgetsBindingObserver {
DateTime? _isOutOfSyncSince;
bool _isBusy = false;
@ -240,104 +238,77 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
);
}
return Row(
return Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
),
Expanded(
child: Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
onJoin: () {
if (!SolianTheme.isLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
),
Expanded(
child: ChatEventList(
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
),
if (_isOutOfSyncSince != null)
ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor:
Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _isOutOfSyncSince = null);
},
),
onTap: _isBusy
? null
: () {
_keepUpdateWithServer();
},
),
Obx(() {
if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY();
} else {
return const SizedBox();
}
}),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea(
child: ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm,
placeholder: placeholder,
channel: _channel!,
onSent: (Event item) {
setState(() {
_chatController.addPendingEvent(item);
});
},
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
),
),
),
),
],
child: ChatEventList(
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
),
if (_isOutOfSyncSince != null)
ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _isOutOfSyncSince = null);
},
),
onTap: _isBusy
? null
: () {
_keepUpdateWithServer();
},
),
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),
),
]),
);
if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY();
} else {
return const SizedBox();
}
return const SizedBox();
}),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea(
child: ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm,
placeholder: placeholder,
channel: _channel!,
onSent: (Event item) {
setState(() {
_chatController.addPendingEvent(item);
});
},
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
),
),
),
),
],
);
}),

View File

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

View File

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

View File

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

View File

@ -88,12 +88,10 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void _disconnect() async {
if (await showDisconnectDialog() != true) return;
final ChatCallProvider call = Get.find();
if (call.current.value != null) {
call.disposeRoom();
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
final ChatCallProvider provider = Get.find();
if (provider.current.value != null) {
provider.disposeRoom();
Navigator.pop(context);
}
}
@ -211,7 +209,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
runSpacing: 5,
children: [
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,
onPressed: _disconnect,
),

View File

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

View File

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

View File

@ -73,7 +73,7 @@ class ChatEvent extends StatelessWidget {
return Container(
key: Key('m${item.uuid}attachments-box'),
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(
maxHeight: 720,
),
@ -296,8 +296,8 @@ class ChatEvent extends StatelessWidget {
),
],
).paddingSymmetric(horizontal: 12),
_buildLinkExpansion().paddingOnly(left: 52, right: 8),
_buildAttachment(context).paddingOnly(left: 56, right: 8),
_buildLinkExpansion().paddingOnly(left: 52),
_buildAttachment(context).paddingOnly(left: 56),
],
);
}