💫 Auto hide or show call controls
This commit is contained in:
parent
c88fcc84da
commit
7e8993fbd2
@ -131,6 +131,8 @@ class ChatEventController {
|
||||
}
|
||||
|
||||
insertEvent(LocalEvent entry) {
|
||||
if (entry.channelId != channel?.id) return;
|
||||
|
||||
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid);
|
||||
if (idx != -1) {
|
||||
currentEvents[idx] = entry;
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_rx/get_rx.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:solian/models/call.dart';
|
||||
@ -16,6 +17,7 @@ class ChatCallProvider extends GetxController {
|
||||
|
||||
RxBool isReady = false.obs;
|
||||
RxBool isMounted = false.obs;
|
||||
RxBool isInitialized = false.obs;
|
||||
|
||||
String? token;
|
||||
String? endpoint;
|
||||
@ -151,6 +153,8 @@ class ChatCallProvider extends GetxController {
|
||||
void onRoomDidUpdate() => sortParticipants();
|
||||
|
||||
void setupRoom() {
|
||||
if(isInitialized.value) return;
|
||||
|
||||
sortParticipants();
|
||||
room.addListener(onRoomDidUpdate);
|
||||
WidgetsBindingCompatible.instance?.addPostFrameCallback(
|
||||
@ -160,6 +164,8 @@ class ChatCallProvider extends GetxController {
|
||||
if (lkPlatformIsMobile()) {
|
||||
Hardware.instance.setSpeakerphoneOn(true);
|
||||
}
|
||||
|
||||
isInitialized.value = true;
|
||||
}
|
||||
|
||||
void setupRoomListeners({
|
||||
@ -362,6 +368,7 @@ class ChatCallProvider extends GetxController {
|
||||
|
||||
void disposeRoom() {
|
||||
isMounted.value = false;
|
||||
isInitialized.value = false;
|
||||
current.value = null;
|
||||
channel.value = null;
|
||||
room.removeListener(onRoomDidUpdate);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/providers/call.dart';
|
||||
@ -16,11 +17,24 @@ class CallScreen extends StatefulWidget {
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen> {
|
||||
Timer? timer;
|
||||
String currentDuration = '00:00:00';
|
||||
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String _currentDuration = '00:00:00';
|
||||
|
||||
String parseDuration() {
|
||||
bool _showControls = true;
|
||||
CancelableOperation? _hideControlsOperation;
|
||||
|
||||
late final AnimationController _controlsAnimationController =
|
||||
AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
late final Animation<double> _controlsAnimation = CurvedAnimation(
|
||||
parent: _controlsAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
String _parseDuration() {
|
||||
final ChatCallProvider provider = Get.find();
|
||||
if (provider.current.value == null) return '00:00:00';
|
||||
Duration duration =
|
||||
@ -34,18 +48,50 @@ class _CallScreenState extends State<CallScreen> {
|
||||
return formattedTime;
|
||||
}
|
||||
|
||||
void updateDuration() {
|
||||
void _updateDuration() {
|
||||
setState(() {
|
||||
currentDuration = parseDuration();
|
||||
_currentDuration = _parseDuration();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleControls() {
|
||||
if (_showControls) {
|
||||
setState(() => _showControls = false);
|
||||
_controlsAnimationController.animateTo(0);
|
||||
_hideControlsOperation?.cancel();
|
||||
} else {
|
||||
setState(() => _showControls = true);
|
||||
_controlsAnimationController.animateTo(1);
|
||||
_planAutoHideControls();
|
||||
}
|
||||
}
|
||||
|
||||
void _planAutoHideControls() {
|
||||
_hideControlsOperation = CancelableOperation.fromFuture(
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (!mounted) return;
|
||||
if (_showControls) _toggleControls();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Get.find<ChatCallProvider>().setupRoom();
|
||||
super.initState();
|
||||
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration());
|
||||
_updateDuration();
|
||||
_planAutoHideControls();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _updateDuration(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controlsAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -68,13 +114,15 @@ class _CallScreenState extends State<CallScreen> {
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: currentDuration,
|
||||
text: _currentDuration,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Obx(
|
||||
() => Stack(
|
||||
children: [
|
||||
@ -93,10 +141,17 @@ class _CallScreenState extends State<CallScreen> {
|
||||
),
|
||||
),
|
||||
if (provider.room.localParticipant != null)
|
||||
ControlsWidget(
|
||||
SizeTransition(
|
||||
sizeFactor: _controlsAnimation,
|
||||
axis: Axis.vertical,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ControlsWidget(
|
||||
provider.room,
|
||||
provider.room.localParticipant!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
@ -107,7 +162,8 @@ class _CallScreenState extends State<CallScreen> {
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: math.max(0, provider.participantTracks.length),
|
||||
itemCount:
|
||||
math.max(0, provider.participantTracks.length),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final track = provider.participantTracks[index];
|
||||
if (track.participant.sid ==
|
||||
@ -143,6 +199,10 @@ class _CallScreenState extends State<CallScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_toggleControls();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -150,16 +210,16 @@ class _CallScreenState extends State<CallScreen> {
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
timer?.cancel();
|
||||
timer = null;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void activate() {
|
||||
timer ??= Timer.periodic(
|
||||
_timer ??= Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => updateDuration(),
|
||||
(_) => _updateDuration(),
|
||||
);
|
||||
super.activate();
|
||||
}
|
||||
|
@ -109,10 +109,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
break;
|
||||
case 'calls.new':
|
||||
final payload = Call.fromJson(event.payload!);
|
||||
if (payload.channel.id == _channel!.id) {
|
||||
setState(() => _ongoingCall = payload);
|
||||
}
|
||||
break;
|
||||
case 'calls.end':
|
||||
final payload = Call.fromJson(event.payload!);
|
||||
if (payload.channel.id == _channel!.id) {
|
||||
setState(() => _ongoingCall = null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
@ -23,7 +23,7 @@ class ControlsWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
CameraPosition position = CameraPosition.front;
|
||||
CameraPosition _position = CameraPosition.front;
|
||||
|
||||
List<MediaDevice>? _audioInputs;
|
||||
List<MediaDevice>? _audioOutputs;
|
||||
@ -36,25 +36,25 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
participant.addListener(onChange);
|
||||
_participant.addListener(onChange);
|
||||
_subscription = Hardware.instance.onDeviceChange.stream
|
||||
.listen((List<MediaDevice> devices) {
|
||||
revertDevices(devices);
|
||||
_revertDevices(devices);
|
||||
});
|
||||
Hardware.instance.enumerateDevices().then(revertDevices);
|
||||
Hardware.instance.enumerateDevices().then(_revertDevices);
|
||||
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
participant.removeListener(onChange);
|
||||
_participant.removeListener(onChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
LocalParticipant get participant => widget.participant;
|
||||
LocalParticipant get _participant => widget.participant;
|
||||
|
||||
void revertDevices(List<MediaDevice> devices) async {
|
||||
void _revertDevices(List<MediaDevice> devices) async {
|
||||
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
|
||||
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
|
||||
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
|
||||
@ -63,7 +63,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
|
||||
void onChange() => setState(() {});
|
||||
|
||||
bool get isMuted => participant.isMuted;
|
||||
bool get isMuted => _participant.isMuted;
|
||||
|
||||
Future<bool?> showDisconnectDialog() {
|
||||
return showDialog<bool>(
|
||||
@ -85,7 +85,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
void disconnect() async {
|
||||
void _disconnect() async {
|
||||
if (await showDisconnectDialog() != true) return;
|
||||
|
||||
final ChatCallProvider provider = Get.find();
|
||||
@ -95,59 +95,59 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
void disableAudio() async {
|
||||
await participant.setMicrophoneEnabled(false);
|
||||
void _disableAudio() async {
|
||||
await _participant.setMicrophoneEnabled(false);
|
||||
}
|
||||
|
||||
void enableAudio() async {
|
||||
await participant.setMicrophoneEnabled(true);
|
||||
void _enableAudio() async {
|
||||
await _participant.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
void disableVideo() async {
|
||||
await participant.setCameraEnabled(false);
|
||||
void _disableVideo() async {
|
||||
await _participant.setCameraEnabled(false);
|
||||
}
|
||||
|
||||
void enableVideo() async {
|
||||
await participant.setCameraEnabled(true);
|
||||
void _enableVideo() async {
|
||||
await _participant.setCameraEnabled(true);
|
||||
}
|
||||
|
||||
void selectAudioOutput(MediaDevice device) async {
|
||||
void _selectAudioOutput(MediaDevice device) async {
|
||||
await widget.room.setAudioOutputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void selectAudioInput(MediaDevice device) async {
|
||||
void _selectAudioInput(MediaDevice device) async {
|
||||
await widget.room.setAudioInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void selectVideoInput(MediaDevice device) async {
|
||||
void _selectVideoInput(MediaDevice device) async {
|
||||
await widget.room.setVideoInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void setSpeakerphoneOn() {
|
||||
void _setSpeakerphoneOn() {
|
||||
_speakerphoneOn = !_speakerphoneOn;
|
||||
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void toggleCamera() async {
|
||||
final track = participant.videoTrackPublications.firstOrNull?.track;
|
||||
void _toggleCamera() async {
|
||||
final track = _participant.videoTrackPublications.firstOrNull?.track;
|
||||
if (track == null) return;
|
||||
|
||||
try {
|
||||
final newPosition = position.switched();
|
||||
final newPosition = _position.switched();
|
||||
await track.setCameraPosition(newPosition);
|
||||
setState(() {
|
||||
position = newPosition;
|
||||
_position = newPosition;
|
||||
});
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void enableScreenShare() async {
|
||||
void _enableScreenShare() async {
|
||||
if (lkPlatformIsDesktop()) {
|
||||
try {
|
||||
final source = await showDialog<DesktopCapturerSource>(
|
||||
@ -163,7 +163,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
maxFrameRate: 15.0,
|
||||
),
|
||||
);
|
||||
await participant.publishVideoTrack(track);
|
||||
await _participant.publishVideoTrack(track);
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
context.showErrorDialog(message);
|
||||
@ -177,7 +177,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
maxFrameRate: 30.0,
|
||||
),
|
||||
);
|
||||
await participant.publishVideoTrack(track);
|
||||
await _participant.publishVideoTrack(track);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -188,11 +188,11 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
return;
|
||||
}
|
||||
|
||||
await participant.setScreenShareEnabled(true, captureScreenAudio: true);
|
||||
await _participant.setScreenShareEnabled(true, captureScreenAudio: true);
|
||||
}
|
||||
|
||||
void disableScreenShare() async {
|
||||
await participant.setScreenShareEnabled(false);
|
||||
void _disableScreenShare() async {
|
||||
await _participant.setScreenShareEnabled(false);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -210,12 +210,12 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
icon: Transform.flip(
|
||||
flipX: true, child: const Icon(Icons.exit_to_app)),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: disconnect,
|
||||
onPressed: _disconnect,
|
||||
),
|
||||
if (participant.isMicrophoneEnabled())
|
||||
if (_participant.isMicrophoneEnabled())
|
||||
if (lkPlatformIs(PlatformType.android))
|
||||
IconButton(
|
||||
onPressed: disableAudio,
|
||||
onPressed: _disableAudio,
|
||||
icon: const Icon(Icons.mic),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: 'callMicrophoneOff'.tr,
|
||||
@ -227,7 +227,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
return [
|
||||
PopupMenuItem<MediaDevice>(
|
||||
value: null,
|
||||
onTap: isMuted ? enableAudio : disableAudio,
|
||||
onTap: isMuted ? _enableAudio : _disableAudio,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.mic_off),
|
||||
title: Text(isMuted
|
||||
@ -246,7 +246,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(device.label),
|
||||
),
|
||||
onTap: () => selectAudioInput(device),
|
||||
onTap: () => _selectAudioInput(device),
|
||||
);
|
||||
})
|
||||
];
|
||||
@ -254,19 +254,19 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: enableAudio,
|
||||
onPressed: _enableAudio,
|
||||
icon: const Icon(Icons.mic_off),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: 'callMicrophoneOn'.tr,
|
||||
),
|
||||
if (participant.isCameraEnabled())
|
||||
if (_participant.isCameraEnabled())
|
||||
PopupMenuButton<MediaDevice>(
|
||||
icon: const Icon(Icons.videocam_sharp),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem<MediaDevice>(
|
||||
value: null,
|
||||
onTap: disableVideo,
|
||||
onTap: _disableVideo,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.videocam_off),
|
||||
title: Text('callCameraOff'.tr),
|
||||
@ -283,7 +283,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(device.label),
|
||||
),
|
||||
onTap: () => selectVideoInput(device),
|
||||
onTap: () => _selectVideoInput(device),
|
||||
);
|
||||
})
|
||||
];
|
||||
@ -291,17 +291,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: enableVideo,
|
||||
onPressed: _enableVideo,
|
||||
icon: const Icon(Icons.videocam_off),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: 'callCameraOn'.tr,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(position == CameraPosition.back
|
||||
icon: Icon(_position == CameraPosition.back
|
||||
? Icons.video_camera_back
|
||||
: Icons.video_camera_front),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => toggleCamera(),
|
||||
onPressed: () => _toggleCamera(),
|
||||
tooltip: 'callVideoFlip'.tr,
|
||||
),
|
||||
if (!lkPlatformIs(PlatformType.iOS))
|
||||
@ -327,7 +327,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(device.label),
|
||||
),
|
||||
onTap: () => selectAudioOutput(device),
|
||||
onTap: () => _selectAudioOutput(device),
|
||||
);
|
||||
})
|
||||
];
|
||||
@ -336,7 +336,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
|
||||
IconButton(
|
||||
onPressed: Hardware.instance.canSwitchSpeakerphone
|
||||
? setSpeakerphoneOn
|
||||
? _setSpeakerphoneOn
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
icon: Icon(
|
||||
@ -344,18 +344,18 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
),
|
||||
tooltip: 'callSpeakerphoneToggle'.tr,
|
||||
),
|
||||
if (participant.isScreenShareEnabled())
|
||||
if (_participant.isScreenShareEnabled())
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor_outlined),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => disableScreenShare(),
|
||||
onPressed: () => _disableScreenShare(),
|
||||
tooltip: 'callScreenOff'.tr,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => enableScreenShare(),
|
||||
onPressed: () => _enableScreenShare(),
|
||||
tooltip: 'callScreenOn'.tr,
|
||||
),
|
||||
],
|
||||
|
@ -219,7 +219,7 @@ class InteractiveParticipantWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: GestureDetector(
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
|
@ -50,7 +50,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: async
|
||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||
|
@ -68,6 +68,7 @@ dependencies:
|
||||
markdown_toolbar: ^0.5.0
|
||||
animations: ^2.0.11
|
||||
avatar_stack: ^1.2.0
|
||||
async: ^2.11.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Loading…
Reference in New Issue
Block a user