💄 Optimized voice chat

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

View File

@ -24,8 +24,8 @@
android:name="de.julianassmann.flutter_background.IsolateHolderService" android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="mediaProjection" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -204,17 +204,23 @@ class _ChatCallState extends State<ChatCall> {
List<ParticipantTrack> userMediaTracks = []; List<ParticipantTrack> userMediaTracks = [];
List<ParticipantTrack> screenTracks = []; List<ParticipantTrack> screenTracks = [];
for (var participant in _callRoom.remoteParticipants.values) { for (var participant in _callRoom.remoteParticipants.values) {
for (var t in participant.videoTrackPublications) { for (var t in participant.trackPublications.values) {
if (t.isScreenShare) { if (t.isScreenShare) {
screenTracks.add(ParticipantTrack( screenTracks.add(ParticipantTrack(
participant: participant, participant: participant,
videoTrack: t.track, videoTrack: t.track as VideoTrack,
isScreenShare: true, isScreenShare: true,
)); ));
} else if (t.track is VideoTrack) {
userMediaTracks.add(ParticipantTrack(
participant: participant,
videoTrack: t.track as VideoTrack,
isScreenShare: false,
));
} else { } else {
userMediaTracks.add(ParticipantTrack( userMediaTracks.add(ParticipantTrack(
participant: participant, participant: participant,
videoTrack: t.track, videoTrack: null,
isScreenShare: false, isScreenShare: false,
)); ));
} }
@ -247,19 +253,25 @@ class _ChatCallState extends State<ChatCall> {
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
}); });
final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications; final localParticipantTracks = _callRoom.localParticipant?.trackPublications.values;
if (localParticipantTracks != null) { if (localParticipantTracks != null) {
for (var t in localParticipantTracks) { for (var t in localParticipantTracks) {
if (t.isScreenShare) { if (t.isScreenShare) {
screenTracks.add(ParticipantTrack( screenTracks.add(ParticipantTrack(
participant: _callRoom.localParticipant!, participant: _callRoom.localParticipant!,
videoTrack: t.track, videoTrack: t.track as VideoTrack,
isScreenShare: true, isScreenShare: true,
)); ));
} else if (t.track is VideoTrack) {
userMediaTracks.add(ParticipantTrack(
participant: _callRoom.localParticipant!,
videoTrack: t.track as VideoTrack,
isScreenShare: false,
));
} else { } else {
userMediaTracks.add(ParticipantTrack( userMediaTracks.add(ParticipantTrack(
participant: _callRoom.localParticipant!, participant: _callRoom.localParticipant!,
videoTrack: t.track, videoTrack: null,
isScreenShare: false, isScreenShare: false,
)); ));
} }
@ -363,7 +375,6 @@ class _ChatCallState extends State<ChatCall> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( return IndentWrapper(
title: AppLocalizations.of(context)!.chatCall, title: AppLocalizations.of(context)!.chatCall,
noSafeArea: true,
hideDrawer: true, hideDrawer: true,
child: FutureBuilder( child: FutureBuilder(
future: exchangeToken(), future: exchangeToken(),
@ -377,15 +388,14 @@ class _ChatCallState extends State<ChatCall> {
Column( Column(
children: [ children: [
Expanded( Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _participantTracks.isNotEmpty child: _participantTracks.isNotEmpty
? ParticipantWidget.widgetFor(_participantTracks.first) ? ParticipantWidget.widgetFor(_participantTracks.first)
: Container(), : Container(),
), ),
if (_callRoom.localParticipant != null) ),
SafeArea( if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!),
top: false,
child: ControlsWidget(_callRoom, _callRoom.localParticipant!),
)
], ],
), ),
Positioned( Positioned(
@ -398,7 +408,7 @@ class _ChatCallState extends State<ChatCall> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: math.max(0, _participantTracks.length - 1), itemCount: math.max(0, _participantTracks.length - 1),
itemBuilder: (BuildContext context, int index) => SizedBox( itemBuilder: (BuildContext context, int index) => SizedBox(
width: 180, width: 120,
height: 120, height: 120,
child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), child: ParticipantWidget.widgetFor(_participantTracks[index + 1]),
), ),
@ -423,8 +433,8 @@ class _ChatCallState extends State<ChatCall> {
WakelockPlus.disable(); WakelockPlus.disable();
(() async { (() async {
_callRoom.removeListener(onRoomDidUpdate); _callRoom.removeListener(onRoomDidUpdate);
await _callRoom.disconnect();
await _callListener.dispose(); await _callListener.dispose();
await _callRoom.disconnect();
await _callRoom.dispose(); await _callRoom.dispose();
})(); })();
super.dispose(); super.dispose();

View File

@ -294,7 +294,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
value: null, value: null,
onTap: disableVideo, onTap: disableVideo,
child: ListTile( child: ListTile(
leading: const Icon(Icons.videocam_off, color: Colors.white), leading: const Icon(Icons.videocam_off),
title: Text(AppLocalizations.of(context)!.chatCallVideoOff), 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/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.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_info.dart';
import 'package:solian/widgets/chat/call/participant_stats.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; TrackPublication? get _firstAudioPublication;
Account? _userinfoMetadata;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -104,23 +109,19 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
super.didUpdateWidget(oldWidget); 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) => []; List<Widget> extraWidgets(bool isScreenShare) => [];
@override @override
Widget build(BuildContext ctx) => Container( Widget build(BuildContext ctx) {
foregroundDecoration: BoxDecoration( return Container(
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( child: Stack(
children: [ children: [
// Video // Video
@ -131,7 +132,10 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
_activeVideoTrack!, _activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
) )
: const NoVideoWidget(), : NoContentWidget(
userinfo: _userinfoMetadata,
isSpeaking: widget.participant.isSpeaking,
),
), ),
if (widget.showStatsLayer) if (widget.showStatsLayer)
Positioned( Positioned(
@ -139,7 +143,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
right: 30, right: 30,
child: ParticipantStatsWidget( child: ParticipantStatsWidget(
participant: widget.participant, participant: widget.participant,
)), ),
),
// Bottom bar // Bottom bar
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
@ -152,8 +157,7 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
title: widget.participant.name.isNotEmpty title: widget.participant.name.isNotEmpty
? '${widget.participant.name} (${widget.participant.identity})' ? '${widget.participant.name} (${widget.participant.identity})'
: widget.participant.identity, : widget.participant.identity,
audioAvailable: audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
_firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality, connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare, isScreenShare: widget.isScreenShare,
), ),
@ -164,6 +168,7 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
), ),
); );
} }
}
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> { class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
@override @override
@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic
pub: _firstAudioPublication!, pub: _firstAudioPublication!,
icon: Icons.volume_up, icon: Icons.volume_up,
), ),
// Menu for RemoteTrackPublication<RemoteVideoTrack>
if (_videoPublication != null) if (_videoPublication != null)
RemoteTrackPublicationMenuWidget( RemoteTrackPublicationMenuWidget(
pub: _videoPublication!, pub: _videoPublication!,

View File

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

View File

@ -30,5 +30,11 @@
<string>public.app-category.social-networking</string> <string>public.app-category.social-networking</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow you add photo to your message or post</string>
<key>NSCameraUsageDescription</key>
<string>Allow you take photo/video for your message or post</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow you record audio for your message or post</string>
</dict> </dict>
</plist> </plist>

View File

@ -14,6 +14,8 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array/> <array/>
</dict> </dict>