💄 Optimized voice chat

This commit is contained in:
2024-04-28 00:07:32 +08:00
parent 34dee3773d
commit 541df5c3bc
9 changed files with 174 additions and 94 deletions

View File

@ -294,7 +294,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
value: null,
onTap: disableVideo,
child: ListTile(
leading: const Icon(Icons.videocam_off, color: Colors.white),
leading: const Icon(Icons.videocam_off),
title: Text(AppLocalizations.of(context)!.chatCallVideoOff),
),
),

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:solian/models/account.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'dart:math' as math;
class NoContentWidget extends StatefulWidget {
final Account? userinfo;
final bool isSpeaking;
const NoContentWidget({super.key, this.userinfo, required this.isSpeaking});
@override
State<NoContentWidget> createState() => _NoContentWidgetState();
}
class _NoContentWidgetState extends State<NoContentWidget> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(vsync: this);
}
@override
void didUpdateWidget(NoContentWidget old) {
super.didUpdateWidget(old);
if (widget.isSpeaking) {
_animationController.repeat(reverse: true);
} else {
_animationController.animateTo(0, duration: 300.ms).then((_) => _animationController.reset());
}
}
@override
Widget build(BuildContext context) {
final radius = math.min(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) * 0.1;
return Container(
alignment: Alignment.center,
child: Center(
child: Animate(
autoPlay: false,
controller: _animationController,
effects: [
CustomEffect(
begin: widget.isSpeaking ? 2 : 0,
end: 8,
curve: Curves.easeInOut,
duration: 1250.ms,
builder: (context, value, child) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
border: value > 0 ? Border.all(color: Colors.green, width: value) : null,
),
child: child,
),
)
],
child: AccountAvatar(
source: widget.userinfo!.avatar,
radius: radius,
direct: true,
)),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View File

@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
class NoVideoWidget extends StatelessWidget {
const NoVideoWidget({super.key});
@override
Widget build(BuildContext context) => Container(
alignment: Alignment.center,
child: LayoutBuilder(
builder: (ctx, constraints) => Icon(
Icons.videocam_off_outlined,
color: Theme.of(context).colorScheme.primary,
size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
),
),
);
}

View File

@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.dart';
import 'package:solian/widgets/chat/call/no_video.dart';
import 'package:solian/widgets/chat/call/no_content.dart';
import 'package:solian/widgets/chat/call/participant_info.dart';
import 'package:solian/widgets/chat/call/participant_stats.dart';
@ -83,6 +86,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
TrackPublication? get _firstAudioPublication;
Account? _userinfoMetadata;
@override
void initState() {
super.initState();
@ -104,65 +109,65 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
super.didUpdateWidget(oldWidget);
}
void onParticipantChanged() => setState(() {});
void onParticipantChanged() {
setState(() {
if (widget.participant.metadata != null) {
_userinfoMetadata = Account.fromJson(jsonDecode(widget.participant.metadata!));
}
});
}
List<Widget> extraWidgets(bool isScreenShare) => [];
@override
Widget build(BuildContext ctx) => Container(
foregroundDecoration: BoxDecoration(
border: widget.participant.isSpeaking && !widget.isScreenShare
? Border.all(
width: 5,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
decoration: BoxDecoration(
color: Theme.of(ctx).cardColor,
),
child: Stack(
children: [
// Video
InkWell(
onTap: () => setState(() => _visible = !_visible),
child: _activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: const NoVideoWidget(),
),
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(
participant: widget.participant,
)),
// Bottom bar
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
...extraWidgets(widget.isScreenShare),
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? '${widget.participant.name} (${widget.participant.identity})'
: widget.participant.identity,
audioAvailable:
_firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
Widget build(BuildContext ctx) {
return Container(
child: Stack(
children: [
// Video
InkWell(
onTap: () => setState(() => _visible = !_visible),
child: _activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isSpeaking: widget.participant.isSpeaking,
),
],
),
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(
participant: widget.participant,
),
),
],
),
);
// Bottom bar
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
...extraWidgets(widget.isScreenShare),
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? '${widget.participant.name} (${widget.participant.identity})'
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
),
],
),
),
],
),
);
}
}
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic
pub: _firstAudioPublication!,
icon: Icons.volume_up,
),
// Menu for RemoteTrackPublication<RemoteVideoTrack>
if (_videoPublication != null)
RemoteTrackPublicationMenuWidget(
pub: _videoPublication!,

View File

@ -17,7 +17,7 @@ class ParticipantInfoWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
color: Colors.black.withOpacity(0.3),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
@ -31,6 +31,7 @@ class ParticipantInfoWidget extends StatelessWidget {
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
isScreenShare