💫 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) {
if (entry.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid);
if (idx != -1) {
currentEvents[idx] = entry;

View File

@ -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);

View File

@ -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,80 +114,94 @@ class _CallScreenState extends State<CallScreen> {
),
const TextSpan(text: '\n'),
TextSpan(
text: currentDuration,
text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall,
),
]),
),
),
body: SafeArea(
child: Obx(
() => Stack(
children: [
Column(
children: [
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: provider.focusTrack.value != null
? InteractiveParticipantWidget(
isFixed: false,
participant: provider.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Obx(
() => Stack(
children: [
Column(
children: [
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: provider.focusTrack.value != null
? InteractiveParticipantWidget(
isFixed: false,
participant: provider.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
),
),
if (provider.room.localParticipant != null)
ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, provider.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = provider.participantTracks[index];
if (track.participant.sid ==
provider.focusTrack.value?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixed: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
provider
.focusTrack.value?.participant.sid) {
provider.changeFocusTrack(track);
}
},
if (provider.room.localParticipant != null)
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
),
);
},
),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount:
math.max(0, provider.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = provider.participantTracks[index];
if (track.participant.sid ==
provider.focusTrack.value?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixed: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
provider
.focusTrack.value?.participant.sid) {
provider.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
),
],
],
),
),
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();
}

View File

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

View File

@ -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,
),
],

View File

@ -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,

View File

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

View File

@ -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: