💄 Optimize call

This commit is contained in:
2025-08-01 01:03:01 +08:00
parent 9f9f42071a
commit 1f7caaeaac
5 changed files with 111 additions and 47 deletions

View File

@@ -726,5 +726,6 @@
"reactionHeart": "Heart",
"selectMicrophone": "Select Microphone",
"selectCamera": "Select Camera",
"switchedTo": "Switched to {}"
"switchedTo": "Switched to {}",
"connecting": "Connecting"
}

View File

@@ -49,7 +49,7 @@ class CallScreen extends HookConsumerWidget {
Text(
callState.isConnected
? formatDuration(callState.duration)
: 'Connecting',
: 'connecting',
style: const TextStyle(fontSize: 14),
),
],
@@ -61,13 +61,7 @@ class CallScreen extends HookConsumerWidget {
spacing: 4,
children: [
for (final live in callNotifier.participants)
SpeakingRippleAvatar(
isSpeaking: live.isSpeaking,
isMuted: live.isMuted,
audioLevel: live.remoteParticipant.audioLevel,
identity: live.participant.identity,
size: 30,
),
SpeakingRippleAvatar(live: live, size: 30),
const Gap(8),
],
),
@@ -131,11 +125,7 @@ class CallScreen extends HookConsumerWidget {
children: [
for (final live in participants)
SpeakingRippleAvatar(
isSpeaking: live.isSpeaking,
isMuted: live.isMuted,
audioLevel:
live.remoteParticipant.audioLevel,
identity: live.participant.identity,
live: live,
size: 72,
).padding(horizontal: 4),
],

View File

@@ -338,11 +338,7 @@ class CallOverlayBar extends HookConsumerWidget {
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking: lastSpeaker.isSpeaking,
isMuted: lastSpeaker.isMuted,
audioLevel:
lastSpeaker.remoteParticipant.audioLevel,
identity: lastSpeaker.participant.identity,
live: lastSpeaker,
size: 36,
).center(),
);

View File

@@ -0,0 +1,94 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart';
import 'package:island/widgets/account/account_nameplate.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
class CallParticipantCard extends HookConsumerWidget {
final CallParticipantLive live;
const CallParticipantCard({super.key, required this.live});
@override
Widget build(BuildContext context, WidgetRef ref) {
final width =
math.min(MediaQuery.of(context).size.width - 80, 360).toDouble();
return PopupCard(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
child: SizedBox(
width: width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
children: [
Row(
children: [
const Icon(Symbols.wifi, size: 16),
const Gap(8),
Text(switch (live.remoteParticipant.connectionQuality) {
ConnectionQuality.excellent => 'Excellent',
ConnectionQuality.good => 'Good',
ConnectionQuality.poor => 'Bad',
ConnectionQuality.lost => 'Lost',
_ => 'Connecting',
}),
],
),
],
).padding(horizontal: 20, top: 16),
AccountNameplate(
name: live.participant.identity,
isOutlined: false,
),
],
),
),
);
}
}
class CallParticipantGestureDetector extends StatelessWidget {
final CallParticipantLive participant;
final Widget child;
const CallParticipantGestureDetector({
super.key,
required this.participant,
required this.child,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: child,
onTapDown: (details) {
showCallParticipantCard(
context,
participant,
offset: details.localPosition,
);
},
);
}
}
Future<void> showCallParticipantCard(
BuildContext context,
CallParticipantLive participant, {
Offset? offset,
}) async {
await showPopupCard<void>(
offset: offset ?? Offset.zero,
context: context,
builder: (context) => CallParticipantCard(live: participant),
alignment: Alignment.center,
dimBackground: true,
);
}

View File

@@ -2,34 +2,24 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart';
import 'package:island/screens/account/profile.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/chat/call_participant_card.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SpeakingRippleAvatar extends HookConsumerWidget {
final bool isSpeaking;
final bool isMuted;
final double audioLevel;
final String identity;
final CallParticipantLive live;
final double size;
const SpeakingRippleAvatar({
super.key,
required this.isSpeaking,
required this.isMuted,
required this.audioLevel,
required this.identity,
this.size = 96,
});
const SpeakingRippleAvatar({super.key, required this.live, this.size = 96});
@override
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(identity));
final account = ref.watch(accountProvider(live.participant.identity));
final avatarRadius = size / 2;
final clampedLevel = audioLevel.clamp(0.0, 1.0);
final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0);
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox(
width: size + 8,
@@ -37,7 +27,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: avatarRadius,
end: isSpeaking ? rippleRadius : avatarRadius,
end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
@@ -45,7 +35,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
return Stack(
alignment: Alignment.center,
children: [
if (isSpeaking)
if (live.remoteParticipant.isSpeaking)
Container(
width: animatedRadius * 2,
height: animatedRadius * 2,
@@ -61,8 +51,8 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
decoration: BoxDecoration(shape: BoxShape.circle),
child: account.when(
data:
(value) => AccountPfcGestureDetector(
uname: identity,
(value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: size / 2,
@@ -80,7 +70,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
),
),
),
if (isMuted)
if (live.remoteParticipant.isMuted)
Positioned(
bottom: 4,
right: 4,
@@ -118,7 +108,6 @@ class CallParticipantTile extends HookConsumerWidget {
live.remoteParticipant.trackPublications.values
.where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
.isNotEmpty;
final audioLevel = live.remoteParticipant.audioLevel;
if (hasVideo) {
return Stack(
@@ -159,13 +148,7 @@ class CallParticipantTile extends HookConsumerWidget {
],
);
} else {
return SpeakingRippleAvatar(
isSpeaking: live.isSpeaking,
isMuted: live.isMuted,
audioLevel: audioLevel,
identity: live.participant.identity,
size: 84,
);
return SpeakingRippleAvatar(size: 84, live: live);
}
}
}