Optimized UI of call actions

This commit is contained in:
LittleSheep 2024-04-28 23:05:59 +08:00
parent 1e4fda7daa
commit e665b44507
6 changed files with 244 additions and 176 deletions

View File

@ -101,8 +101,9 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners();
}
void clearNonRealtime() {
void clearRealtime() {
notifications = notifications.where((x) => !x.isRealtime).toList();
notifyListeners();
}
void allRead() {

View File

@ -11,6 +11,7 @@ import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/call/controls.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/chat/call/participant.dart';
import 'package:solian/widgets/chat/call/participant_menu.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -49,9 +50,10 @@ class _ChatCallState extends State<ChatCall> {
late EventsListener<RoomEvent> _callListener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusParticipant;
Future<void> checkPermissions() async {
if(lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return;
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return;
await Permission.camera.request();
await Permission.microphone.request();
@ -275,6 +277,7 @@ class _ChatCallState extends State<ChatCall> {
setState(() {
_participantTracks = [...screenTracks, localTrack, ...userMediaTrackList];
_focusParticipant ??= _participantTracks.first;
});
}
@ -386,8 +389,11 @@ class _ChatCallState extends State<ChatCall> {
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _participantTracks.isNotEmpty
? ParticipantWidget.widgetFor(_participantTracks.first)
child: _focusParticipant != null
? InteractiveParticipantWidget(
participant: _focusParticipant!,
onTap: () {},
)
: Container(),
),
),
@ -399,22 +405,34 @@ class _ChatCallState extends State<ChatCall> {
right: 0,
top: 0,
child: SizedBox(
height: 120 + 16,
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, _participantTracks.length - 1),
itemBuilder: (BuildContext context, int index) => Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
width: 120,
height: 120,
color: Theme.of(context).cardColor,
child: ParticipantWidget.widgetFor(_participantTracks[index + 1]),
itemCount: math.max(0, _participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = _participantTracks[index];
if (track.participant.sid == _focusParticipant?.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(
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid != _focusParticipant?.participant.sid) {
setState(() => _focusParticipant = track);
}
},
),
),
),
),
);
},
),
),
),
@ -443,3 +461,44 @@ class _ChatCallState extends State<ChatCall> {
super.dispose();
}
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final ParticipantTrack participant;
final Function() onTap;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
required this.participant,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
);
}
}

View File

@ -21,8 +21,6 @@ class _NotificationScreenState extends State<NotificationScreen> {
final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) => nty.allRead());
return IndentWrapper(
noSafeArea: true,
hideDrawer: true,
@ -71,6 +69,14 @@ class _NotificationScreenState extends State<NotificationScreen> {
),
);
}
@override
void dispose() {
final nty = context.read<NotifyProvider>();
nty.allRead();
nty.clearRealtime();
super.dispose();
}
}
class NotificationItem extends StatelessWidget {

View File

@ -60,6 +60,7 @@ class _NoContentWidgetState extends State<NoContentWidget> with SingleTickerProv
],
child: AccountAvatar(
source: widget.userinfo!.avatar,
backgroundColor: Colors.transparent,
radius: radius,
direct: true,
)),

View File

@ -78,8 +78,6 @@ class RemoteParticipantWidget extends ParticipantWidget {
}
abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends State<T> {
bool _visible = true;
VideoTrack? get _activeVideoTrack;
TrackPublication? get _videoPublication;
@ -117,24 +115,19 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
});
}
List<Widget> extraWidgets(bool isScreenShare) => [];
@override
Widget build(BuildContext ctx) {
return Stack(
children: [
InkWell(
onTap: () => setState(() => _visible = !_visible),
child: _activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isSpeaking: widget.participant.isSpeaking,
),
),
_activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isSpeaking: widget.participant.isSpeaking,
),
if (widget.showStatsLayer)
Positioned(
top: 30,
@ -149,10 +142,9 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
...extraWidgets(widget.isScreenShare),
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? '${widget.participant.name} (${widget.participant.identity})'
? widget.participant.name
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
@ -190,143 +182,4 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
@override
List<Widget> extraWidgets(bool isScreenShare) => [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Menu for RemoteTrackPublication<RemoteAudioTrack>
if (_firstAudioPublication != null && !isScreenShare)
RemoteTrackPublicationMenuWidget(
pub: _firstAudioPublication!,
icon: Icons.volume_up,
),
if (_videoPublication != null)
RemoteTrackPublicationMenuWidget(
pub: _videoPublication!,
icon: isScreenShare ? Icons.monitor : Icons.videocam,
),
if (_videoPublication != null)
RemoteTrackFPSMenuWidget(
pub: _videoPublication!,
icon: Icons.menu,
),
if (_videoPublication != null)
RemoteTrackQualityMenuWidget(
pub: _videoPublication!,
icon: Icons.monitor_outlined,
),
],
),
];
}
class RemoteTrackPublicationMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackPublicationMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
child: PopupMenuButton<Function>(
tooltip: 'Subscribe menu',
icon: Icon(icon,
color: {
TrackSubscriptionState.notAllowed: Colors.red,
TrackSubscriptionState.unsubscribed: Colors.grey,
TrackSubscriptionState.subscribed: Colors.green,
}[pub.subscriptionState]),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
if (pub.subscribed == false)
PopupMenuItem(
child: const Text('Subscribe'),
value: () => pub.subscribe(),
)
else if (pub.subscribed == true)
PopupMenuItem(
child: const Text('Un-subscribe'),
value: () => pub.unsubscribe(),
),
],
),
);
}
class RemoteTrackFPSMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackFPSMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred FPS',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
PopupMenuItem(
child: const Text('30'),
value: () => pub.setVideoFPS(30),
),
PopupMenuItem(
child: const Text('15'),
value: () => pub.setVideoFPS(15),
),
PopupMenuItem(
child: const Text('8'),
value: () => pub.setVideoFPS(8),
),
],
),
);
}
class RemoteTrackQualityMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackQualityMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred Quality',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
PopupMenuItem(
child: const Text('HIGH'),
value: () => pub.setVideoQuality(VideoQuality.HIGH),
),
PopupMenuItem(
child: const Text('MEDIUM'),
value: () => pub.setVideoQuality(VideoQuality.MEDIUM),
),
PopupMenuItem(
child: const Text('LOW'),
value: () => pub.setVideoQuality(VideoQuality.LOW),
),
],
),
);
}

View File

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ParticipantMenu extends StatefulWidget {
final RemoteParticipant participant;
final VideoTrack? videoTrack;
final bool isScreenShare;
final bool showStatsLayer;
const ParticipantMenu({
super.key,
required this.participant,
this.videoTrack,
this.isScreenShare = false,
this.showStatsLayer = false,
});
@override
State<ParticipantMenu> createState() => _ParticipantMenuState();
}
class _ParticipantMenuState extends State<ParticipantMenu> {
@override
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
@override
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
void tookAction() {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
child: Text(
AppLocalizations.of(context)!.action,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
Expanded(
child: ListView(
children: [
if (_firstAudioPublication != null && !widget.isScreenShare)
ListTile(
leading: Icon(
Icons.volume_up,
color: {
TrackSubscriptionState.notAllowed: Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
TrackSubscriptionState.subscribed: Theme.of(context).colorScheme.primary,
}[_firstAudioPublication!.subscriptionState],
),
title: Text(
_firstAudioPublication!.subscribed
? AppLocalizations.of(context)!.chatCallMute
: AppLocalizations.of(context)!.chatCallUnMute,
),
onTap: () {
if (_firstAudioPublication!.subscribed) {
_firstAudioPublication!.unsubscribe();
} else {
_firstAudioPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null)
ListTile(
leading: Icon(
widget.isScreenShare ? Icons.monitor : Icons.videocam,
color: {
TrackSubscriptionState.notAllowed: Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
TrackSubscriptionState.subscribed: Theme.of(context).colorScheme.primary,
}[_videoPublication!.subscriptionState],
),
title: Text(
_videoPublication!.subscribed
? AppLocalizations.of(context)!.chatCallVideoOff
: AppLocalizations.of(context)!.chatCallVideoOn,
),
onTap: () {
if (_videoPublication!.subscribed) {
_videoPublication!.unsubscribe();
} else {
_videoPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[30, 15, 8].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.fps == x ? Icons.check_box_outlined : Icons.check_box_outline_blank,
),
title: Text('Set preferred frame-per-second to $x'),
onTap: () {
_videoPublication!.setVideoFPS(x);
tookAction();
},
),
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[
('High', VideoQuality.HIGH),
('Medium', VideoQuality.MEDIUM),
('Low', VideoQuality.LOW),
].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.videoQuality == x.$2 ? Icons.check_box_outlined : Icons.check_box_outline_blank,
),
title: Text('Set preferred quality to ${x.$1}'),
onTap: () {
_videoPublication!.setVideoQuality(x.$2);
tookAction();
},
),
),
],
),
),
],
);
}
}