Files
Qlyra/lib/frontend/widgets/floating_call_badge.dart
T

712 lines
19 KiB
Dart

import 'dart:async';
import 'dart:ui' show lerpDouble;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'
show MediaStream, RTCVideoRenderer, RTCVideoViewObjectFit;
import 'package:material_symbols_icons/symbols.dart';
import '../../core/calls/active_call.dart';
import '../../core/calls/call_session.dart';
import '../../core/config/app_colors.dart';
import '../../core/config/app_fonts.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp;
import '../screens/calls/call_screen.dart';
import 'call_video_view.dart';
import 'custom_notification.dart';
import 'draggable_floating_layer.dart';
import 'glossy_pill.dart';
import 'lottie_slash_icon.dart';
import 'small_spinner.dart';
class FloatingCallBadgeLayer extends StatelessWidget {
const FloatingCallBadgeLayer({super.key});
@override
Widget build(BuildContext context) {
final call = ActiveCall.instance;
return ValueListenableBuilder<ActiveCallPresentation?>(
valueListenable: call.current,
builder: (context, active, _) {
if (active == null) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: call.screenVisible,
builder: (context, onScreen, _) => onScreen
? const SizedBox.shrink()
: _CallBadge(key: ObjectKey(active.session), call: active),
);
},
);
}
}
typedef _BadgeSnapshot = ({
bool muted,
bool video,
bool speaking,
bool hasVideo,
bool reconnecting,
CallSessionState state,
});
class _CallBadge extends StatefulWidget {
const _CallBadge({super.key, required this.call});
final ActiveCallPresentation call;
@override
State<_CallBadge> createState() => _CallBadgeState();
}
class _CallBadgeState extends State<_CallBadge>
with SingleTickerProviderStateMixin {
static const double _collapsedWidth = 120;
static const double _expandedWidth = 152;
static const double _collapsedHeight = 112;
static const double _expandedHeight = 160;
static const double _avatarSize = 56;
static const double _buttonSize = 38;
static const double _controlsWidth = _expandedWidth - 20;
static const Duration _autoCollapse = Duration(seconds: 4);
late final AnimationController _reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
reverseDuration: const Duration(milliseconds: 200),
);
StreamSubscription<CallSessionState>? _stateSub;
StreamSubscription<void>? _infoSub;
StreamSubscription<MediaStream>? _remoteStreamSub;
StreamSubscription<int>? _participantStreamSub;
Timer? _collapseTimer;
bool _chromeMounted = false;
_BadgeSnapshot? _rendered;
RTCVideoRenderer? _renderer;
MediaStream? _videoStream;
bool _rendererPending = false;
bool _videoBusy = false;
CallSession get _session => widget.call.session;
@override
void initState() {
super.initState();
_stateSub = _session.stateStream.listen((_) => _sync());
_infoSub = _session.infoUpdates.listen((_) => _sync());
_remoteStreamSub = _session.remoteStreamStream.listen((_) => _sync());
_participantStreamSub = _session.participantStreamUpdates.listen(
(_) => _sync(),
);
_reveal.addStatusListener(_onRevealStatus);
_attachVideo();
_rendered = _snapshot();
}
@override
void dispose() {
_stateSub?.cancel();
_infoSub?.cancel();
_remoteStreamSub?.cancel();
_participantStreamSub?.cancel();
_collapseTimer?.cancel();
_renderer?.srcObject = null;
_renderer?.dispose();
_reveal.dispose();
super.dispose();
}
void _sync() {
if (!mounted) return;
_attachVideo();
final next = _snapshot();
if (next == _rendered) return;
_rendered = next;
setState(() {});
}
void _redraw() {
if (!mounted) return;
_rendered = _snapshot();
setState(() {});
}
_BadgeSnapshot _snapshot() => (
muted: _session.isMuted,
video: _session.localVideo,
speaking: _peerSpeaking,
hasVideo: _videoStream != null,
reconnecting: _session.isReconnecting,
state: _session.currentState,
);
MediaStream? _pickVideoStream() {
final session = _session;
for (final participant in session.participants) {
if (participant.isSelf) continue;
if (!participant.videoEnabled && !participant.screenSharing) continue;
final stream = session.streamOf(participant.id);
if (stream != null && stream.getVideoTracks().isNotEmpty) return stream;
}
final remote = session.remoteStream;
if (session.peerVideo &&
remote != null &&
remote.getVideoTracks().isNotEmpty) {
return remote;
}
return null;
}
void _attachVideo() {
final next = _pickVideoStream();
if (identical(next, _videoStream)) return;
_videoStream = next;
final renderer = _renderer;
if (renderer != null) {
renderer.srcObject = next;
return;
}
if (next != null && !_rendererPending) {
_rendererPending = true;
unawaited(_createRenderer());
}
}
Future<void> _createRenderer() async {
final renderer = RTCVideoRenderer();
try {
await renderer.initialize();
} catch (_) {
_rendererPending = false;
return;
}
_rendererPending = false;
if (!mounted || _videoStream == null) {
await renderer.dispose();
return;
}
renderer.srcObject = _videoStream;
_renderer = renderer;
_redraw();
}
void _restartCollapseTimer() {
_collapseTimer?.cancel();
_collapseTimer = Timer(_autoCollapse, _collapse);
}
void _mountChrome() {
if (_chromeMounted) return;
setState(() => _chromeMounted = true);
}
void _onRevealStatus(AnimationStatus status) {
if (status != AnimationStatus.dismissed || !_chromeMounted) return;
setState(() => _chromeMounted = false);
}
void _expandControls() {
_mountChrome();
_reveal.forward();
_restartCollapseTimer();
}
void _collapse() {
_collapseTimer?.cancel();
if (mounted) _reveal.reverse();
}
void _toggleControls() {
Haptics.tap();
if (_reveal.value > 0.5) {
_collapse();
} else {
_expandControls();
}
}
void _onDragStart() {
_collapseTimer?.cancel();
_mountChrome();
_reveal.forward();
}
void _onDragEnd() => _restartCollapseTimer();
Future<void> _toggleMute() async {
Haptics.tap();
_restartCollapseTimer();
await _session.setMuted(!_session.isMuted);
_redraw();
}
Future<void> _toggleVideo() async {
if (_videoBusy) return;
Haptics.tap();
_restartCollapseTimer();
final l10n = AppLocalizations.of(context)!;
setState(() => _videoBusy = true);
await WidgetsBinding.instance.endOfFrame;
try {
await _session.setVideoEnabled(!_session.localVideo);
} catch (e) {
_notify(l10n.callCameraUnavailable(e));
} finally {
_videoBusy = false;
_redraw();
}
}
void _notify(String message) {
final overlay = KometApp.navigatorKey.currentState?.overlay;
if (overlay == null) return;
showCustomNotificationOnOverlay(overlay, message);
}
Future<void> _hangup() async {
Haptics.medium();
_collapseTimer?.cancel();
await _session.hangup();
}
void _openCall() {
Haptics.tap();
_collapse();
final navigator = KometApp.navigatorKey.currentState;
if (navigator == null) return;
navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.call.name,
avatarUrl: widget.call.avatarUrl,
session: _session,
isGroup: widget.call.isGroup,
),
),
);
}
bool get _peerSpeaking =>
_session.participants.any((p) => !p.isSelf && _session.isSpeaking(p.id));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final text = (theme.textTheme.bodyMedium ?? const TextStyle()).copyWith(
color: cs.onSurface,
);
final title = _title(cs, l10n);
final face = _face(cs);
final expand = _chromeMounted ? _expandButton(cs, l10n) : null;
final controls = _chromeMounted ? _controls(cs, l10n) : null;
return AnimatedBuilder(
animation: _reveal,
builder: (context, _) {
final t = Curves.easeOutCubic.transform(_reveal.value);
final size = Size(
lerpDouble(_collapsedWidth, _expandedWidth, t)!,
lerpDouble(_collapsedHeight, _expandedHeight, t)!,
);
return DraggableFloatingLayer(
storageKey: 'call_badge',
size: size,
onTap: _toggleControls,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
child: SizedBox.fromSize(
size: size,
child: _card(
cs,
t,
text: text,
title: title,
face: face,
expand: expand,
controls: controls,
),
),
);
},
);
}
Widget _card(
ColorScheme cs,
double t, {
required TextStyle text,
required Widget title,
required Widget face,
required Widget? expand,
required Widget? controls,
}) {
final radius = BorderRadius.circular(26);
return DefaultTextStyle(
style: text,
child: GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: radius,
depth: 10,
child: ClipRRect(
borderRadius: radius,
child: Stack(
children: [
Positioned(
left: 12,
right: lerpDouble(12, 44, t)!,
top: 10,
child: title,
),
if (expand != null)
Positioned(top: 7, right: 7, child: _fade(t, expand)),
Positioned(
left: 0,
right: 0,
top: 44,
bottom: lerpDouble(12, 58, t)!,
child: Center(child: face),
),
if (controls != null)
Positioned(
left: 0,
right: 0,
bottom: 10,
child: _fade(
t,
SizedBox(
height: _buttonSize,
child: OverflowBox(
minWidth: _controlsWidth,
maxWidth: _controlsWidth,
minHeight: _buttonSize,
maxHeight: _buttonSize,
alignment: Alignment.center,
child: controls,
),
),
),
),
],
),
),
),
);
}
Widget _fade(double t, Widget child) => IgnorePointer(
ignoring: t < 0.5,
child: Opacity(
opacity: t.clamp(0.0, 1.0),
child: Transform.scale(scale: lerpDouble(0.82, 1, t)!, child: child),
),
);
Widget _title(ColorScheme cs, AppLocalizations l10n) {
final name = widget.call.name.isEmpty
? l10n.callUnknownName
: widget.call.name;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
_CallStatusLine(session: _session, color: cs.onSurfaceVariant),
],
);
}
Widget _expandButton(ColorScheme cs, AppLocalizations l10n) {
return Semantics(
label: l10n.callTooltipExpand,
button: true,
child: SizedBox.square(
dimension: 30,
child: GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(15),
depth: 6,
onTap: _openCall,
child: Center(
child: Icon(
Symbols.open_in_full,
size: 16,
weight: 600,
color: cs.onSurface,
),
),
),
),
);
}
Widget _face(ColorScheme cs) {
final muted = _session.isMuted;
final renderer = _renderer;
final showVideo = _videoStream != null && renderer != null;
return SizedBox.square(
dimension: _avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: _avatarSize,
height: _avatarSize,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surfaceContainerHighest,
border: Border.all(
color: _peerSpeaking
? kSuccessGreen
: Colors.white.withValues(alpha: 0.10),
width: _peerSpeaking ? 2.5 : 1.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.32),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: showVideo
? CallVideoView(
renderer: renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: _avatar(cs),
)
: _avatar(cs),
),
if (muted)
Positioned(
right: -2,
bottom: -2,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: cs.surfaceContainerHigh, width: 2),
),
child: Icon(
Symbols.mic_off,
size: 12,
fill: 1,
color: cs.onSurfaceVariant,
),
),
),
],
),
);
}
Widget _avatar(ColorScheme cs) {
final url = widget.call.avatarUrl;
if (url == null || url.isEmpty) return _avatarFallback(cs);
return CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: 192,
memCacheHeight: 192,
errorWidget: (_, _, _) => _avatarFallback(cs),
);
}
Widget _avatarFallback(ColorScheme cs) {
final name = widget.call.name;
final letter = (name.isEmpty ? '?' : name[0]).toUpperCase();
return ColoredBox(
color: cs.primaryContainer,
child: Center(
child: Text(
letter,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: _avatarSize * 0.38,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
),
),
),
);
}
Widget _controls(ColorScheme cs, AppLocalizations l10n) {
final muted = _session.isMuted;
final video = _session.localVideo;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_BadgeButton(
size: _buttonSize,
background: muted ? cs.primary : cs.surfaceContainerHighest,
label: muted ? l10n.callUnmute : l10n.callMute,
onTap: _toggleMute,
child: LottieSlashIcon(
asset: 'assets/lottie/ic_mic_on_to_off.json',
slashed: muted,
color: muted ? cs.onPrimary : cs.onSurface,
size: 20,
),
),
_BadgeButton(
size: _buttonSize,
background: video ? cs.primary : cs.surfaceContainerHighest,
label: l10n.callVideoLabel,
onTap: _toggleVideo,
child: _videoBusy
? SmallSpinner(
size: 16,
color: video ? cs.onPrimary : cs.onSurface,
)
: LottieSlashIcon(
asset: 'assets/lottie/ic_videocam_on_to_off.json',
slashed: !video,
color: video ? cs.onPrimary : cs.onSurface,
size: 20,
),
),
_BadgeButton(
size: _buttonSize,
background: kDangerRed,
label: l10n.callEndButton,
onTap: _hangup,
child: const Icon(
Symbols.call_end,
size: 20,
fill: 1,
color: Colors.white,
),
),
],
);
}
}
class _BadgeButton extends StatelessWidget {
const _BadgeButton({
required this.size,
required this.background,
required this.label,
required this.onTap,
required this.child,
});
final double size;
final Color background;
final String label;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
button: true,
child: SizedBox.square(
dimension: size,
child: GlossyPill(
color: background,
borderRadius: BorderRadius.circular(size / 2),
depth: 7,
onTap: onTap,
child: Center(child: child),
),
),
);
}
}
class _CallStatusLine extends StatefulWidget {
const _CallStatusLine({required this.session, required this.color});
final CallSession session;
final Color color;
@override
State<_CallStatusLine> createState() => _CallStatusLineState();
}
class _CallStatusLineState extends State<_CallStatusLine> {
Timer? _ticker;
@override
void initState() {
super.initState();
_syncTicker();
}
@override
void didUpdateWidget(_CallStatusLine old) {
super.didUpdateWidget(old);
_syncTicker();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
void _syncTicker() {
final counting =
widget.session.currentState == CallSessionState.active &&
!widget.session.isReconnecting;
if (counting == (_ticker != null)) return;
if (!counting) {
_ticker?.cancel();
_ticker = null;
return;
}
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
String _label(AppLocalizations l10n) {
final session = widget.session;
if (session.isReconnecting) return l10n.callStatusConnecting;
return switch (session.currentState) {
CallSessionState.connecting => l10n.callStatusConnecting,
CallSessionState.ringing => l10n.callStatusRinging,
CallSessionState.ended => l10n.callStatusEnded,
CallSessionState.active => formatSecondsMmSs(
session.elapsedSeconds,
padMinutes: true,
),
};
}
@override
Widget build(BuildContext context) {
return Text(
_label(AppLocalizations.of(context)!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: widget.color,
fontSize: 11,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
}