💫 Auto hide or show call controls

This commit is contained in:
LittleSheep 2024-08-02 18:09:07 +08:00
parent c88fcc84da
commit 7e8993fbd2
8 changed files with 200 additions and 125 deletions

View File

@ -131,6 +131,8 @@ class ChatEventController {
} }
insertEvent(LocalEvent entry) { insertEvent(LocalEvent entry) {
if (entry.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid); final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid);
if (idx != -1) { if (idx != -1) {
currentEvents[idx] = entry; currentEvents[idx] = entry;

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_rx/get_rx.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
@ -16,6 +17,7 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs; RxBool isReady = false.obs;
RxBool isMounted = false.obs; RxBool isMounted = false.obs;
RxBool isInitialized = false.obs;
String? token; String? token;
String? endpoint; String? endpoint;
@ -151,6 +153,8 @@ class ChatCallProvider extends GetxController {
void onRoomDidUpdate() => sortParticipants(); void onRoomDidUpdate() => sortParticipants();
void setupRoom() { void setupRoom() {
if(isInitialized.value) return;
sortParticipants(); sortParticipants();
room.addListener(onRoomDidUpdate); room.addListener(onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback( WidgetsBindingCompatible.instance?.addPostFrameCallback(
@ -160,6 +164,8 @@ class ChatCallProvider extends GetxController {
if (lkPlatformIsMobile()) { if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true); Hardware.instance.setSpeakerphoneOn(true);
} }
isInitialized.value = true;
} }
void setupRoomListeners({ void setupRoomListeners({
@ -362,6 +368,7 @@ class ChatCallProvider extends GetxController {
void disposeRoom() { void disposeRoom() {
isMounted.value = false; isMounted.value = false;
isInitialized.value = false;
current.value = null; current.value = null;
channel.value = null; channel.value = null;
room.removeListener(onRoomDidUpdate); room.removeListener(onRoomDidUpdate);

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/call.dart'; import 'package:solian/providers/call.dart';
@ -16,11 +17,24 @@ class CallScreen extends StatefulWidget {
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
} }
class _CallScreenState extends State<CallScreen> { class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? timer; Timer? _timer;
String currentDuration = '00:00:00'; 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(); final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00'; if (provider.current.value == null) return '00:00:00';
Duration duration = Duration duration =
@ -34,18 +48,50 @@ class _CallScreenState extends State<CallScreen> {
return formattedTime; return formattedTime;
} }
void updateDuration() { void _updateDuration() {
setState(() { 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 @override
void initState() { void initState() {
Get.find<ChatCallProvider>().setupRoom(); Get.find<ChatCallProvider>().setupRoom();
super.initState(); 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 @override
@ -68,13 +114,15 @@ class _CallScreenState extends State<CallScreen> {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: currentDuration, text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
]), ]),
), ),
), ),
body: SafeArea( body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Obx( child: Obx(
() => Stack( () => Stack(
children: [ children: [
@ -93,10 +141,17 @@ class _CallScreenState extends State<CallScreen> {
), ),
), ),
if (provider.room.localParticipant != null) 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,
provider.room.localParticipant!, provider.room.localParticipant!,
), ),
),
),
], ],
), ),
Positioned( Positioned(
@ -107,7 +162,8 @@ class _CallScreenState extends State<CallScreen> {
height: 128, height: 128,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: math.max(0, provider.participantTracks.length), itemCount:
math.max(0, provider.participantTracks.length),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final track = provider.participantTracks[index]; final track = provider.participantTracks[index];
if (track.participant.sid == 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 @override
void deactivate() { void deactivate() {
timer?.cancel(); _timer?.cancel();
timer = null; _timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
timer ??= Timer.periodic( _timer ??= Timer.periodic(
const Duration(seconds: 1), const Duration(seconds: 1),
(_) => updateDuration(), (_) => _updateDuration(),
); );
super.activate(); super.activate();
} }

View File

@ -109,10 +109,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
break; break;
case 'calls.new': case 'calls.new':
final payload = Call.fromJson(event.payload!); final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = payload); setState(() => _ongoingCall = payload);
}
break; break;
case 'calls.end': case 'calls.end':
final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = null); setState(() => _ongoingCall = null);
}
break; break;
} }
}); });

View File

@ -23,7 +23,7 @@ class ControlsWidget extends StatefulWidget {
} }
class _ControlsWidgetState extends State<ControlsWidget> { class _ControlsWidgetState extends State<ControlsWidget> {
CameraPosition position = CameraPosition.front; CameraPosition _position = CameraPosition.front;
List<MediaDevice>? _audioInputs; List<MediaDevice>? _audioInputs;
List<MediaDevice>? _audioOutputs; List<MediaDevice>? _audioOutputs;
@ -36,25 +36,25 @@ class _ControlsWidgetState extends State<ControlsWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
participant.addListener(onChange); _participant.addListener(onChange);
_subscription = Hardware.instance.onDeviceChange.stream _subscription = Hardware.instance.onDeviceChange.stream
.listen((List<MediaDevice> devices) { .listen((List<MediaDevice> devices) {
revertDevices(devices); _revertDevices(devices);
}); });
Hardware.instance.enumerateDevices().then(revertDevices); Hardware.instance.enumerateDevices().then(_revertDevices);
_speakerphoneOn = Hardware.instance.speakerOn ?? false; _speakerphoneOn = Hardware.instance.speakerOn ?? false;
} }
@override @override
void dispose() { void dispose() {
_subscription?.cancel(); _subscription?.cancel();
participant.removeListener(onChange); _participant.removeListener(onChange);
super.dispose(); 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(); _audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList(); _audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); _videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
@ -63,7 +63,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void onChange() => setState(() {}); void onChange() => setState(() {});
bool get isMuted => participant.isMuted; bool get isMuted => _participant.isMuted;
Future<bool?> showDisconnectDialog() { Future<bool?> showDisconnectDialog() {
return showDialog<bool>( return showDialog<bool>(
@ -85,7 +85,7 @@ 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 provider = Get.find();
@ -95,59 +95,59 @@ class _ControlsWidgetState extends State<ControlsWidget> {
} }
} }
void disableAudio() async { void _disableAudio() async {
await participant.setMicrophoneEnabled(false); await _participant.setMicrophoneEnabled(false);
} }
void enableAudio() async { void _enableAudio() async {
await participant.setMicrophoneEnabled(true); await _participant.setMicrophoneEnabled(true);
} }
void disableVideo() async { void _disableVideo() async {
await participant.setCameraEnabled(false); await _participant.setCameraEnabled(false);
} }
void enableVideo() async { void _enableVideo() async {
await participant.setCameraEnabled(true); await _participant.setCameraEnabled(true);
} }
void selectAudioOutput(MediaDevice device) async { void _selectAudioOutput(MediaDevice device) async {
await widget.room.setAudioOutputDevice(device); await widget.room.setAudioOutputDevice(device);
setState(() {}); setState(() {});
} }
void selectAudioInput(MediaDevice device) async { void _selectAudioInput(MediaDevice device) async {
await widget.room.setAudioInputDevice(device); await widget.room.setAudioInputDevice(device);
setState(() {}); setState(() {});
} }
void selectVideoInput(MediaDevice device) async { void _selectVideoInput(MediaDevice device) async {
await widget.room.setVideoInputDevice(device); await widget.room.setVideoInputDevice(device);
setState(() {}); setState(() {});
} }
void setSpeakerphoneOn() { void _setSpeakerphoneOn() {
_speakerphoneOn = !_speakerphoneOn; _speakerphoneOn = !_speakerphoneOn;
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn); Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
setState(() {}); setState(() {});
} }
void toggleCamera() async { void _toggleCamera() async {
final track = participant.videoTrackPublications.firstOrNull?.track; final track = _participant.videoTrackPublications.firstOrNull?.track;
if (track == null) return; if (track == null) return;
try { try {
final newPosition = position.switched(); final newPosition = _position.switched();
await track.setCameraPosition(newPosition); await track.setCameraPosition(newPosition);
setState(() { setState(() {
position = newPosition; _position = newPosition;
}); });
} catch (error) { } catch (error) {
return; return;
} }
} }
void enableScreenShare() async { void _enableScreenShare() async {
if (lkPlatformIsDesktop()) { if (lkPlatformIsDesktop()) {
try { try {
final source = await showDialog<DesktopCapturerSource>( final source = await showDialog<DesktopCapturerSource>(
@ -163,7 +163,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
maxFrameRate: 15.0, maxFrameRate: 15.0,
), ),
); );
await participant.publishVideoTrack(track); await _participant.publishVideoTrack(track);
} catch (e) { } catch (e) {
final message = e.toString(); final message = e.toString();
context.showErrorDialog(message); context.showErrorDialog(message);
@ -177,7 +177,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
maxFrameRate: 30.0, maxFrameRate: 30.0,
), ),
); );
await participant.publishVideoTrack(track); await _participant.publishVideoTrack(track);
return; return;
} }
@ -188,11 +188,11 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return; return;
} }
await participant.setScreenShareEnabled(true, captureScreenAudio: true); await _participant.setScreenShareEnabled(true, captureScreenAudio: true);
} }
void disableScreenShare() async { void _disableScreenShare() async {
await participant.setScreenShareEnabled(false); await _participant.setScreenShareEnabled(false);
} }
@override @override
@ -210,12 +210,12 @@ class _ControlsWidgetState extends State<ControlsWidget> {
icon: Transform.flip( icon: Transform.flip(
flipX: true, child: 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,
), ),
if (participant.isMicrophoneEnabled()) if (_participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android)) if (lkPlatformIs(PlatformType.android))
IconButton( IconButton(
onPressed: disableAudio, onPressed: _disableAudio,
icon: const Icon(Icons.mic), icon: const Icon(Icons.mic),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOff'.tr, tooltip: 'callMicrophoneOff'.tr,
@ -227,7 +227,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return [ return [
PopupMenuItem<MediaDevice>( PopupMenuItem<MediaDevice>(
value: null, value: null,
onTap: isMuted ? enableAudio : disableAudio, onTap: isMuted ? _enableAudio : _disableAudio,
child: ListTile( child: ListTile(
leading: const Icon(Icons.mic_off), leading: const Icon(Icons.mic_off),
title: Text(isMuted title: Text(isMuted
@ -246,7 +246,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank), : const Icon(Icons.check_box_outline_blank),
title: Text(device.label), title: Text(device.label),
), ),
onTap: () => selectAudioInput(device), onTap: () => _selectAudioInput(device),
); );
}) })
]; ];
@ -254,19 +254,19 @@ class _ControlsWidgetState extends State<ControlsWidget> {
) )
else else
IconButton( IconButton(
onPressed: enableAudio, onPressed: _enableAudio,
icon: const Icon(Icons.mic_off), icon: const Icon(Icons.mic_off),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOn'.tr, tooltip: 'callMicrophoneOn'.tr,
), ),
if (participant.isCameraEnabled()) if (_participant.isCameraEnabled())
PopupMenuButton<MediaDevice>( PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp), icon: const Icon(Icons.videocam_sharp),
itemBuilder: (BuildContext context) { itemBuilder: (BuildContext context) {
return [ return [
PopupMenuItem<MediaDevice>( PopupMenuItem<MediaDevice>(
value: null, value: null,
onTap: disableVideo, onTap: _disableVideo,
child: ListTile( child: ListTile(
leading: const Icon(Icons.videocam_off), leading: const Icon(Icons.videocam_off),
title: Text('callCameraOff'.tr), title: Text('callCameraOff'.tr),
@ -283,7 +283,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank), : const Icon(Icons.check_box_outline_blank),
title: Text(device.label), title: Text(device.label),
), ),
onTap: () => selectVideoInput(device), onTap: () => _selectVideoInput(device),
); );
}) })
]; ];
@ -291,17 +291,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
) )
else else
IconButton( IconButton(
onPressed: enableVideo, onPressed: _enableVideo,
icon: const Icon(Icons.videocam_off), icon: const Icon(Icons.videocam_off),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callCameraOn'.tr, tooltip: 'callCameraOn'.tr,
), ),
IconButton( IconButton(
icon: Icon(position == CameraPosition.back icon: Icon(_position == CameraPosition.back
? Icons.video_camera_back ? Icons.video_camera_back
: Icons.video_camera_front), : Icons.video_camera_front),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
onPressed: () => toggleCamera(), onPressed: () => _toggleCamera(),
tooltip: 'callVideoFlip'.tr, tooltip: 'callVideoFlip'.tr,
), ),
if (!lkPlatformIs(PlatformType.iOS)) if (!lkPlatformIs(PlatformType.iOS))
@ -327,7 +327,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank), : const Icon(Icons.check_box_outline_blank),
title: Text(device.label), title: Text(device.label),
), ),
onTap: () => selectAudioOutput(device), onTap: () => _selectAudioOutput(device),
); );
}) })
]; ];
@ -336,7 +336,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
if (!kIsWeb && lkPlatformIs(PlatformType.iOS)) if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton( IconButton(
onPressed: Hardware.instance.canSwitchSpeakerphone onPressed: Hardware.instance.canSwitchSpeakerphone
? setSpeakerphoneOn ? _setSpeakerphoneOn
: null, : null,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
icon: Icon( icon: Icon(
@ -344,18 +344,18 @@ class _ControlsWidgetState extends State<ControlsWidget> {
), ),
tooltip: 'callSpeakerphoneToggle'.tr, tooltip: 'callSpeakerphoneToggle'.tr,
), ),
if (participant.isScreenShareEnabled()) if (_participant.isScreenShareEnabled())
IconButton( IconButton(
icon: const Icon(Icons.monitor_outlined), icon: const Icon(Icons.monitor_outlined),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
onPressed: () => disableScreenShare(), onPressed: () => _disableScreenShare(),
tooltip: 'callScreenOff'.tr, tooltip: 'callScreenOff'.tr,
) )
else else
IconButton( IconButton(
icon: const Icon(Icons.monitor), icon: const Icon(Icons.monitor),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
onPressed: () => enableScreenShare(), onPressed: () => _enableScreenShare(),
tooltip: 'callScreenOn'.tr, tooltip: 'callScreenOn'.tr,
), ),
], ],

View File

@ -219,7 +219,7 @@ class InteractiveParticipantWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: GestureDetector(
child: Container( child: Container(
width: width, width: width,
height: height, height: height,

View File

@ -50,7 +50,7 @@ packages:
source: hosted source: hosted
version: "2.5.0" version: "2.5.0"
async: async:
dependency: transitive dependency: "direct main"
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"

View File

@ -68,6 +68,7 @@ dependencies:
markdown_toolbar: ^0.5.0 markdown_toolbar: ^0.5.0
animations: ^2.0.11 animations: ^2.0.11
avatar_stack: ^1.2.0 avatar_stack: ^1.2.0
async: ^2.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: