Files
Qlyra/lib/frontend/screens/calls/call_screen.dart
T

1677 lines
49 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math' show cos, pi;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'
show MediaStream, RTCVideoRenderer, RTCVideoValue, RTCVideoViewObjectFit;
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart';
import '../../../core/calls/active_call.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/calls/call_info.dart';
import '../../../core/calls/call_session.dart';
import '../../../core/config/app_colors.dart';
import '../../../core/config/call_no_mute.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/call_video_view.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import 'call_mic_sheet.dart';
import 'call_participants_sheet.dart';
import 'komet_hub.dart';
import '../../../core/config/app_fonts.dart';
class CallScreen extends StatefulWidget {
final String name;
final String? avatarUrl;
final CallSession? session;
final IncomingCall? incoming;
final bool isGroup;
final bool autoAccept;
const CallScreen({
super.key,
required this.name,
this.avatarUrl,
this.session,
this.incoming,
this.isGroup = false,
this.autoAccept = false,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
CallSession? _session;
StreamSubscription<CallSessionState>? _stateSub;
StreamSubscription<void>? _canceledSub;
StreamSubscription<void>? _infoSub;
StreamSubscription<void>? _kometSub;
StreamSubscription<CallChatMessage>? _chatSub;
StreamSubscription<MediaStream>? _remoteStreamSub;
bool _chatOpen = false;
CallSessionState _state = CallSessionState.connecting;
bool _incomingPending = false;
bool _isMuted = false;
bool _isSpeaker = false;
late final AnimationController _dotsController;
late final AnimationController _videoController;
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final Map<int, RTCVideoRenderer> _tileRenderers = {};
StreamSubscription<int>? _tileStreamSub;
bool _rendererReady = false;
bool _localRendererReady = false;
bool _videoAttached = false;
MediaStream? _pendingStream;
Color? _seedKey;
ColorScheme? _scheme;
late String _name = widget.name;
late String? _avatarUrl = widget.avatarUrl;
final Map<int, _PeerInfo> _peerInfo = {};
bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2;
void _onTileStream(int id) {
final stream = _session?.streamOf(id);
final existing = _tileRenderers[id];
if (existing != null) {
existing.srcObject = stream;
if (mounted) setState(() {});
return;
}
if (stream == null) return;
unawaited(_createTileRenderer(id, stream));
}
Future<void> _createTileRenderer(int id, MediaStream stream) async {
final renderer = RTCVideoRenderer();
await renderer.initialize();
if (!mounted) {
await renderer.dispose();
return;
}
renderer.srcObject = stream;
_tileRenderers[id] = renderer;
setState(() {});
}
RTCVideoRenderer? _tileRenderer(CallParticipant p) {
if (p.isSelf) return null;
final own = _tileRenderers[p.id];
final src = own?.srcObject;
if (src != null && src.getVideoTracks().isNotEmpty) return own;
return _tileVideoReady ? _remoteRenderer : null;
}
bool get _tileVideoReady {
if (_session?.topology == 'SERVER') return false;
final others = (_session?.participants ?? const <CallParticipant>[])
.where((x) => !x.isSelf)
.length;
if (others != 1) return false;
final src = _remoteRenderer.srcObject;
return src != null && src.getVideoTracks().isNotEmpty;
}
@override
void initState() {
super.initState();
ActiveCall.instance.enterScreen();
_dotsController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
_videoController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
);
_initRenderer();
_incomingPending = widget.session == null && widget.incoming != null;
if (widget.session != null) _bind(widget.session!);
final incoming = widget.incoming;
if (incoming != null && (_name.isEmpty || _avatarUrl == null)) {
_resolvePeerInfo(incoming.callerId);
}
if (incoming != null) {
_canceledSub = CallController.instance.incomingCanceled.listen((_) {
if (mounted && _incomingPending) _close();
});
}
if (widget.autoAccept && incoming != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _incomingPending) _accept();
});
}
}
Future<void> _resolvePeerInfo(int id) async {
var name = ContactCache.get(id);
var avatar = ContactCache.getAvatar(id);
if (name == null || avatar == null) {
final info = await ContactInfoFetch.get(id);
if (info != null) {
name ??= info.displayName;
avatar ??= info.avatarUrl;
if (name != null) ContactCache.put(id, name);
ContactCache.putAvatar(id, avatar);
}
}
if (!mounted) return;
setState(() {
if (name != null && name.isNotEmpty) _name = name;
if (avatar != null && avatar.isNotEmpty) _avatarUrl = avatar;
});
_publishActiveCall();
}
Future<void> _initRenderer() async {
await _remoteRenderer.initialize();
await _localRenderer.initialize();
if (!mounted) return;
_rendererReady = true;
_localRendererReady = true;
if (_pendingStream != null) {
_remoteRenderer.srcObject = _pendingStream;
_pendingStream = null;
}
_syncLocalPreview();
setState(() {});
}
void _attachStream(MediaStream stream) {
if (!_rendererReady) {
_pendingStream = stream;
return;
}
final hasVideo = stream.getVideoTracks().isNotEmpty;
if (!identical(_remoteRenderer.srcObject, stream)) {
_remoteRenderer.srcObject = stream;
} else if (hasVideo && !_videoAttached) {
_remoteRenderer.srcObject = null;
_remoteRenderer.srcObject = stream;
} else {
return;
}
if (hasVideo) _videoAttached = true;
if (mounted) setState(() {});
}
void _syncVideo() {
if (_session?.peerVideo == true) {
_videoController.forward();
} else {
_videoController.reverse();
}
}
ColorScheme _darkScheme(BuildContext context) {
final seed = Theme.of(context).colorScheme.primary;
if (_seedKey != seed || _scheme == null) {
_seedKey = seed;
_scheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
}
return _scheme!;
}
void _bind(CallSession session) {
_session = session;
_state = session.currentState;
_stateSub = session.stateStream.listen(_onState);
_infoSub = session.infoUpdates.listen((_) {
if (!mounted) return;
_isMuted = session.isMuted;
_resolveParticipants();
_syncVideo();
_syncLocalPreview();
_isSpeaker = session.isSpeaker;
setState(() {});
_publishActiveCall();
});
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
_tileStreamSub = session.participantStreamUpdates.listen(_onTileStream);
_kometSub = session.peerKometDetected.listen((_) => _showKometBadge());
_chatSub = session.chatMessages.listen(_onChatMessage);
if (session.peerIsKomet) {
WidgetsBinding.instance.addPostFrameCallback((_) => _showKometBadge());
}
final existing = session.remoteStream;
if (existing != null) _attachStream(existing);
_resolveParticipants();
_syncVideo();
_isSpeaker = session.isSpeaker;
_publishActiveCall();
}
void _publishActiveCall() {
final session = _session;
if (session == null) return;
ActiveCall.instance.attach(
session: session,
name: _name,
avatarUrl: _avatarUrl,
isGroup: _isGroup,
);
}
void _showKometBadge() {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
showCustomNotification(context, l10n.callKometDetectedNotification);
}
void _onChatMessage(CallChatMessage message) {
if (!mounted || message.mine || _chatOpen) return;
showCustomNotification(context, message.text);
}
Future<void> _openKometHub() async {
final session = _session;
if (session == null) return;
setState(() => _chatOpen = true);
await showKometHub(context, session: session, scheme: _darkScheme(context));
if (mounted) setState(() => _chatOpen = false);
}
void _resolveParticipants() {
final session = _session;
if (session == null) return;
for (final p in session.participants) {
final ext = p.externalId;
if (ext == null || p.isSelf || _peerInfo.containsKey(ext)) continue;
_peerInfo[ext] = const _PeerInfo(resolving: true);
unawaited(_resolveParticipant(ext));
}
}
Future<void> _resolveParticipant(int id) async {
var name = ContactCache.get(id);
var avatar = ContactCache.getAvatar(id);
if (name == null) {
final info = await ContactInfoFetch.get(id);
if (info != null) {
name = info.displayName;
avatar ??= info.avatarUrl;
if (name != null) ContactCache.put(id, name);
ContactCache.putAvatar(id, avatar);
}
}
if (!mounted) return;
setState(() => _peerInfo[id] = _PeerInfo(name: name, avatar: avatar));
}
void _onState(CallSessionState state) {
if (!mounted) return;
setState(() => _state = state);
if (state == CallSessionState.ended) _close();
}
Future<void> _accept() async {
final incoming = widget.incoming;
if (incoming == null) return;
setState(() {
_incomingPending = false;
_state = CallSessionState.connecting;
});
try {
final session = await CallController.instance.acceptIncoming(incoming);
if (!mounted) return;
_bind(session);
} catch (_) {
_close();
}
}
Future<void> _decline() async {
final incoming = widget.incoming;
if (incoming != null) {
await CallController.instance.rejectIncoming(incoming);
}
_close();
}
Future<void> _hangup() async {
final session = _session;
if (session != null) {
await session.hangup();
}
_close();
}
void _close() {
if (!mounted) return;
Navigator.of(context).maybePop();
}
Future<void> _toggleMute() async {
final next = !_isMuted;
setState(() => _isMuted = next);
await _session?.setMuted(next);
}
Future<void> _toggleSpeaker() async {
final session = _session;
if (session == null) return;
final next = !session.isSpeaker;
setState(() => _isSpeaker = next);
await session.setSpeaker(next);
}
bool _videoBusy = false;
Future<void> _toggleVideo() async {
final session = _session;
if (session == null || _videoBusy) return;
final l10n = AppLocalizations.of(context)!;
setState(() => _videoBusy = true);
await WidgetsBinding.instance.endOfFrame;
try {
await session.setVideoEnabled(!session.localVideo);
} catch (e) {
if (mounted) {
showCustomNotification(context, l10n.callCameraUnavailable(e));
}
} finally {
_syncLocalPreview();
if (mounted) setState(() => _videoBusy = false);
}
}
Future<void> _toggleScreen() async {
final session = _session;
if (session == null || _videoBusy) return;
setState(() => _videoBusy = true);
await WidgetsBinding.instance.endOfFrame;
try {
await session.setScreenSharing(!session.localScreen);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Трансляция не запустилась: $e');
}
} finally {
_syncLocalPreview();
if (mounted) setState(() => _videoBusy = false);
}
}
void _syncLocalPreview() {
if (!_localRendererReady) return;
_localRenderer.srcObject = _session?.localVideoStream;
}
@override
void dispose() {
ActiveCall.instance.leaveScreen();
_stateSub?.cancel();
_canceledSub?.cancel();
_infoSub?.cancel();
_kometSub?.cancel();
_chatSub?.cancel();
_remoteStreamSub?.cancel();
_tileStreamSub?.cancel();
for (final renderer in _tileRenderers.values) {
renderer.srcObject = null;
renderer.dispose();
}
_tileRenderers.clear();
_dotsController.dispose();
_videoController.dispose();
if (_rendererReady) _remoteRenderer.srcObject = null;
_remoteRenderer.dispose();
if (_localRendererReady) _localRenderer.srcObject = null;
_localRenderer.dispose();
super.dispose();
}
void _showParticipants() {
final session = _session;
if (session == null) return;
final l10n = AppLocalizations.of(context)!;
showCallParticipantsSheet(
context,
session: session,
scheme: _darkScheme(context),
resolve: (p) {
if (p.isSelf) {
return CallParticipantView(
name: l10n.callParticipantYou,
avatarUrl: _avatarUrl,
);
}
final ext = p.externalId;
final info = ext != null ? _peerInfo[ext] : null;
return CallParticipantView(
name: info?.name?.isNotEmpty == true
? info!.name!
: l10n.callParticipantFallback,
avatarUrl: info?.avatar,
);
},
);
}
void _showMicrophones() {
final session = _session;
if (session == null) return;
showCallMicrophoneSheet(
context,
session: session,
scheme: _darkScheme(context),
);
}
void _showInfoSheet() {
final cs = _darkScheme(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => Theme(
data: Theme.of(context).copyWith(colorScheme: cs),
child: _CallInfoSheet(
session: _session,
incoming: widget.incoming,
name: _displayName,
renderer: _remoteRenderer,
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = _darkScheme(context);
final group = _isGroup && !_incomingPending;
final Widget body = group
? _buildGroupBody(cs)
: AnimatedBuilder(
animation: _videoController,
builder: (context, _) => _buildBody(
cs,
avatar: _buildAvatar(cs),
name: _buildName(cs),
status: _buildStatus(cs),
peerBar: _peerStateBar(cs),
controls: _buildControls(cs),
),
);
return Theme(
data: Theme.of(context).copyWith(colorScheme: cs),
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: cs.surface,
systemNavigationBarIconBrightness: Brightness.light,
),
child: Scaffold(
backgroundColor: cs.surface,
body: Stack(
children: [
body,
if (_session?.localVideo == true || _session?.localScreen == true)
_localPreview(cs),
],
),
),
),
);
}
Widget _localPreview(ColorScheme cs) {
return Positioned(
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: SafeArea(
child: Container(
width: 96,
height: 140,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: cs.surfaceContainerHighest,
border: Border.all(color: cs.outlineVariant, width: 1),
),
child: _localRendererReady && _localRenderer.srcObject != null
? CallVideoView(
renderer: _localRenderer,
mirror: _session?.localScreen != true,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: _localPreviewIcon(cs),
)
: _localPreviewIcon(cs),
),
),
);
}
Widget _localPreviewIcon(ColorScheme cs) => Center(
child: Icon(
_session?.localScreen == true ? Symbols.screen_share : Symbols.videocam,
color: cs.onSurfaceVariant,
size: 28,
),
);
Widget _buildGroupBody(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final participants = _session?.participants ?? const <CallParticipant>[];
return SafeArea(
child: Column(
children: [
_buildTopBar(cs, 0),
const SizedBox(height: 4),
_groupHeader(cs, participants.length),
const SizedBox(height: 8),
Expanded(
child: participants.isEmpty
? Center(child: _statusWithDots(cs, l10n.callStatusConnecting))
: _participantGrid(cs, participants),
),
const SizedBox(height: 12),
_activeControls(cs),
const SizedBox(height: 24),
],
),
);
}
Widget _groupHeader(ColorScheme cs, int count) {
final l10n = AppLocalizations.of(context)!;
final String subtitle;
if (count == 0) {
subtitle = l10n.callGroupConnecting;
} else if (count <= 1) {
subtitle = l10n.callGroupWaitingParticipants;
} else {
subtitle =
'$count ${pluralRu(count, 'участник', 'участника', 'участников')}';
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Text(
_displayName,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 24,
fontWeight: FontWeight.w700,
fontFamily: displayFontOf(context),
),
),
const SizedBox(height: 2),
InkWell(
onTap: count > 0 ? _showParticipants : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
if (count > 0) ...[
const SizedBox(width: 4),
Icon(
Symbols.chevron_right,
size: 16,
color: cs.onSurfaceVariant,
),
],
],
),
),
),
],
),
);
}
Widget _participantGrid(ColorScheme cs, List<CallParticipant> ps) {
final cols = ps.length <= 1
? 1
: ps.length <= 4
? 2
: 3;
return GridView.count(
crossAxisCount: cols,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
mainAxisSpacing: 14,
crossAxisSpacing: 14,
childAspectRatio: 0.84,
children: [for (final p in ps) _participantTile(cs, p)],
);
}
Widget _participantTile(ColorScheme cs, CallParticipant p) {
final l10n = AppLocalizations.of(context)!;
final ext = p.externalId;
final info = ext != null ? _peerInfo[ext] : null;
final name = p.isSelf
? l10n.callParticipantYou
: (info?.name?.isNotEmpty == true
? info!.name!
: l10n.callParticipantFallback);
final url = p.isSelf ? _avatarUrl : info?.avatar;
final muted = p.isSelf ? _isMuted : !p.audioEnabled;
final speaking = !muted && _session?.isSpeaking(p.id) == true;
final renderer = _tileRenderer(p);
final showVideo =
!p.isSelf && (p.videoEnabled || p.screenSharing) && renderer != null;
return GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
depth: 6,
borderSide: speaking
? const BorderSide(color: kSuccessGreen, width: 2.5)
: null,
padding: EdgeInsets.all(showVideo ? 0 : 12),
child: showVideo
? _videoTile(cs, renderer, name, muted, p.handRaised, p.screenSharing)
: _avatarTile(cs, name, url, muted, p.handRaised, p.screenSharing),
);
}
Widget _avatarTile(
ColorScheme cs,
String name,
String? url,
bool muted,
bool hand,
bool screen,
) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest.shortestSide.clamp(48.0, 96.0);
return Center(
child: SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
_circleAvatar(size, cs, name: name, url: url),
if (hand)
Positioned(
top: -2,
right: -2,
child: _tileBadge(
cs,
Symbols.front_hand,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
),
if (screen)
Positioned(
top: -2,
left: -2,
child: _tileBadge(
cs,
Symbols.screen_share,
cs.primaryContainer,
cs.onPrimaryContainer,
),
),
if (muted)
Positioned(
bottom: -2,
right: -2,
child: _tileBadge(
cs,
Symbols.mic_off,
cs.surfaceContainerHighest,
cs.onSurfaceVariant,
),
),
],
),
),
);
},
),
),
const SizedBox(height: 8),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
);
}
Widget _videoTile(
ColorScheme cs,
RTCVideoRenderer renderer,
String name,
bool muted,
bool hand,
bool screen,
) {
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
fit: StackFit.expand,
children: [
CallVideoView(
renderer: renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: ColoredBox(color: cs.surfaceContainerHighest),
),
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Row(
children: [
if (muted)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
Symbols.mic_off,
size: 16,
color: Colors.white,
fill: 1,
),
),
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 4, color: Colors.black)],
),
),
),
],
),
),
if (hand)
Positioned(
top: 8,
right: 8,
child: _tileBadge(
cs,
Symbols.front_hand,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
),
if (screen)
Positioned(
top: 8,
left: 8,
child: _tileBadge(
cs,
Symbols.screen_share,
cs.primaryContainer,
cs.onPrimaryContainer,
),
),
],
),
);
}
Widget _tileBadge(ColorScheme cs, IconData icon, Color bg, Color fg) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: bg,
shape: BoxShape.circle,
border: Border.all(color: cs.surface, width: 2),
),
child: Icon(icon, size: 14, color: fg, fill: 1),
);
}
Widget _buildBody(
ColorScheme cs, {
required Widget avatar,
required Widget name,
required Widget status,
required Widget? peerBar,
required Widget controls,
}) {
final t = Curves.easeInOut.transform(_videoController.value);
final showVideo = t > 0.001 && _remoteRenderer.srcObject != null;
return Stack(
fit: StackFit.expand,
children: [
if (showVideo)
Center(
child: Opacity(
opacity: t,
child: FractionallySizedBox(
widthFactor: 0.62 + 0.38 * t,
heightFactor: 0.46 + 0.54 * t,
child: ClipRRect(
borderRadius: BorderRadius.circular(24 * (1 - t)),
child: ValueListenableBuilder<RTCVideoValue>(
valueListenable: _remoteRenderer,
builder: (context, value, _) {
final ar = value.aspectRatio > 0
? value.aspectRatio
: 16 / 9;
return Center(
child: AspectRatio(
aspectRatio: ar,
child: RepaintBoundary(
child: CallVideoView(
renderer: _remoteRenderer,
objectFit: RTCVideoViewObjectFit
.RTCVideoViewObjectFitCover,
),
),
),
);
},
),
),
),
),
),
if (t > 0.001)
IgnorePointer(
child: Opacity(opacity: t, child: _videoScrim(cs)),
),
SafeArea(
child: Column(
children: [
_buildTopBar(cs, t),
const Spacer(flex: 2),
_collapse(t, avatar),
SizedBox(height: 36 * (1 - t)),
_collapse(t, name),
SizedBox(height: 12 * (1 - t)),
_collapse(t, status),
if (peerBar != null) ...[
SizedBox(height: 14 * (1 - t)),
_collapse(t, peerBar),
],
const Spacer(flex: 5),
controls,
const SizedBox(height: 24),
],
),
),
],
);
}
Widget _collapse(double t, Widget child) {
if (t <= 0.001) return child;
if (t >= 0.999) return const SizedBox.shrink();
return Opacity(
opacity: 1 - t,
child: ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 1 - t,
child: child,
),
),
);
}
Widget _videoScrim(ColorScheme cs) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
cs.surface.withValues(alpha: 0.55),
Colors.transparent,
Colors.transparent,
cs.surface.withValues(alpha: 0.65),
],
stops: const [0.0, 0.34, 0.70, 1.0],
),
),
);
}
Widget _buildTopBar(ColorScheme cs, double t) {
final l10n = AppLocalizations.of(context)!;
final showTimer =
t > 0.001 &&
_session != null &&
_state == CallSessionState.active &&
_session!.mediaConnected;
return SizedBox(
height: 48,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
tooltip: l10n.callTooltipMinimize,
icon: Icon(
Symbols.close_fullscreen,
color: cs.onSurface,
weight: 500,
size: 26,
),
),
),
if (_session != null)
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_session?.peerIsKomet == true)
IconButton(
onPressed: _openKometHub,
tooltip: l10n.callTooltipKometHub,
icon: Icon(
Symbols.auto_awesome,
color: cs.primary,
weight: 500,
size: 26,
),
),
IconButton(
onPressed: _showMicrophones,
tooltip: l10n.callTooltipMicrophone,
icon: Icon(
Symbols.settings_voice,
color: cs.onSurface,
weight: 500,
size: 26,
),
),
IconButton(
onPressed: _showInfoSheet,
tooltip: l10n.callInfoTitle,
icon: Icon(
Symbols.info,
color: cs.onSurface,
weight: 500,
size: 26,
),
),
],
),
),
if (showTimer)
Align(
alignment: Alignment.center,
child: Opacity(
opacity: t,
child: _ElapsedText(
session: _session!,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
),
],
),
);
}
Widget? _peerStateBar(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final session = _session;
if (session == null) return null;
final pills = <Widget>[
if (session.peerMuted)
_statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
if (session.peerVideo)
_statePill(cs, Symbols.videocam, l10n.callPeerCameraOn),
];
if (pills.isEmpty) return null;
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: pills,
);
}
Widget _statePill(ColorScheme cs, IconData icon, String label) {
return GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(100),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
depth: 5,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: cs.onSurfaceVariant, fill: 1),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildAvatar(ColorScheme cs) {
final avatarSize = (MediaQuery.of(context).size.shortestSide * 0.42).clamp(
128.0,
172.0,
);
return _avatarCircle(avatarSize, cs);
}
String get _displayName =>
_name.isEmpty ? AppLocalizations.of(context)!.callUnknownName : _name;
Widget _avatarCircle(double size, ColorScheme cs) =>
_circleAvatar(size, cs, name: _displayName, url: _avatarUrl);
Widget _circleAvatar(
double size,
ColorScheme cs, {
required String name,
String? url,
}) {
return Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surfaceContainerHighest,
border: Border.all(
color: Colors.white.withValues(alpha: 0.10),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: (url != null && url.isNotEmpty)
? CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: 420,
memCacheHeight: 420,
errorWidget: (_, _, _) => _avatarFallback(size, cs, name),
)
: _avatarFallback(size, cs, name),
);
}
Widget _avatarFallback(double size, ColorScheme cs, String name) {
final letter = (name.isEmpty ? '?' : name[0]).toUpperCase();
return Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
letter,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: size * 0.38,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
),
),
);
}
Widget _buildName(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_displayName,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 30,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
height: 1.1,
),
),
);
}
Widget _buildStatus(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
if (!_incomingPending && _state == CallSessionState.active) {
final session = _session;
if (session == null) return const SizedBox.shrink();
if (!session.mediaConnected) {
return _statusWithDots(cs, l10n.callStatusConnecting);
}
return _ElapsedText(
session: session,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
if (_incomingPending) {
return Text(
l10n.callIncoming,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
);
}
String text;
switch (_state) {
case CallSessionState.connecting:
text = l10n.callStatusConnecting;
case CallSessionState.ringing:
text = l10n.callStatusRinging;
case CallSessionState.active:
text = '';
case CallSessionState.ended:
text = l10n.callStatusEnded;
}
return _statusWithDots(cs, text);
}
Widget _statusWithDots(ColorScheme cs, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)),
const SizedBox(width: 4),
_CallingDots(animation: _dotsController, color: cs.onSurfaceVariant),
],
);
}
Widget _buildControls(ColorScheme cs) {
if (_incomingPending) return _incomingControls(cs);
return _activeControls(cs);
}
Widget _incomingControls(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 56),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_CallButton(
icon: Symbols.call_end,
label: l10n.callDecline,
background: kDangerRed,
foreground: Colors.white,
onTap: _decline,
),
_CallButton(
icon: Symbols.call,
label: l10n.callAccept,
background: kSuccessGreen,
foreground: Colors.white,
onTap: _accept,
),
],
),
);
}
Widget _activeControls(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final video = _session?.localVideo == true;
final screen = _session?.localScreen == true;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_CallButton(
icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down,
label: l10n.callSpeaker,
background: _isSpeaker ? cs.primary : cs.surfaceContainerHighest,
foreground: _isSpeaker ? cs.onPrimary : cs.onSurface,
onTap: _toggleSpeaker,
),
_CallButton(
icon: Symbols.videocam,
slashedIcon: Symbols.videocam_off,
slashed: !video,
label: l10n.callVideoLabel,
background: video ? cs.primary : cs.surfaceContainerHighest,
foreground: video ? cs.onPrimary : cs.onSurface,
busy: _videoBusy,
onTap: _toggleVideo,
),
_CallButton(
icon: Symbols.screen_share,
label: l10n.callScreenLabel,
background: screen ? cs.primary : cs.surfaceContainerHighest,
foreground: screen ? cs.onPrimary : cs.onSurface,
busy: _videoBusy,
onTap: _toggleScreen,
),
_CallButton(
icon: Symbols.mic,
slashedIcon: Symbols.mic_off,
slashed: _isMuted,
label: _isMuted
? (CallNoMute.enabled ? l10n.callMicStillLive : l10n.callUnmute)
: l10n.callMute,
background: _isMuted ? cs.primary : cs.surfaceContainerHighest,
foreground: _isMuted ? cs.onPrimary : cs.onSurface,
onTap: _toggleMute,
onLongPress: _showMicrophones,
),
_CallButton(
icon: Symbols.call_end,
label: l10n.callEndButton,
background: kDangerRed,
foreground: Colors.white,
onTap: _hangup,
),
],
),
);
}
}
class _PeerInfo {
final String? name;
final String? avatar;
final bool resolving;
const _PeerInfo({this.name, this.avatar, this.resolving = false});
}
class _CallingDots extends StatelessWidget {
final Animation<double> animation;
final Color color;
const _CallingDots({required this.animation, required this.color});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final v = animation.value;
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final phase = (v + i / 3) % 1.0;
final alpha = 0.3 + 0.7 * (0.5 - 0.5 * cos(phase * 2 * pi));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1.5),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: alpha),
),
),
);
}),
);
},
);
}
}
class _CallButton extends StatelessWidget {
final IconData icon;
final IconData? slashedIcon;
final bool slashed;
final String label;
final Color background;
final Color foreground;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final bool busy;
const _CallButton({
required this.icon,
required this.label,
required this.background,
required this.foreground,
required this.onTap,
this.onLongPress,
this.slashedIcon,
this.slashed = false,
this.busy = false,
});
Widget _buildIcon() {
final crossed = slashedIcon;
if (crossed == null) {
return Icon(icon, color: foreground, size: 26, fill: 1);
}
return AnimatedSlashIcon(
icon: icon,
slashedIcon: crossed,
slashed: slashed,
color: foreground,
size: 26,
fill: 1,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 62,
height: 62,
child: GlossyPill(
color: background,
borderRadius: BorderRadius.circular(31),
onTap: busy ? null : onTap,
onLongPress: busy ? null : onLongPress,
depth: 9,
child: Center(
child: busy
? SmallSpinner(size: 22, color: foreground)
: _buildIcon(),
),
),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
);
}
}
class _ElapsedText extends StatefulWidget {
final CallSession session;
final TextStyle style;
const _ElapsedText({required this.session, required this.style});
@override
State<_ElapsedText> createState() => _ElapsedTextState();
}
class _ElapsedTextState extends State<_ElapsedText> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
formatSecondsMmSs(widget.session.elapsedSeconds, padMinutes: true),
style: widget.style,
);
}
}
class _CallInfoSheet extends StatelessWidget {
final CallSession? session;
final IncomingCall? incoming;
final String name;
final RTCVideoRenderer renderer;
const _CallInfoSheet({
required this.session,
required this.incoming,
required this.name,
required this.renderer,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
final info = session?.info;
final rows = <List<String>>[];
void add(String k, String? v) {
if (v != null && v.isNotEmpty) rows.add([k, v]);
}
add(l10n.callInfoClient, _clientLine(info));
add(l10n.callInfoPlatform, info?.peerPlatform);
add(l10n.callInfoCountry, incoming?.country);
final isContact = incoming?.isContact;
if (isContact != null) {
add(
l10n.callInfoInContacts,
isContact ? l10n.callValueYes : l10n.callValueNo,
);
}
add(l10n.callInfoPeerIp, info?.peerIp);
add(l10n.callInfoPeerNetwork, info?.peerNetwork);
add(l10n.callInfoPath, info?.path);
add(l10n.callInfoCodec, info?.audioCodec);
add(l10n.callInfoServer, info?.region);
add(l10n.callInfoTopology, info?.topology);
add('Conversation ID', info?.conversationId);
if (info?.dtlsFingerprint != null) {
add('DTLS', _shortFp(info!.dtlsFingerprint!));
}
if (session != null) {
add(
l10n.callInfoStatus,
session!.mediaConnected
? l10n.callStatusValueConnected
: l10n.callStatusValueConnecting,
);
add(
l10n.callInfoPeerMic,
session!.peerMuted ? l10n.callMicValueOff : l10n.callMicValueOn,
);
add(
l10n.callInfoPeerCamera,
session!.peerVideo ? l10n.callCameraValueOn : l10n.callCameraValueOff,
);
}
final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0;
add(
l10n.callInfoVideoTrack,
vtracks > 0 ? l10n.callInfoVideoTrackPresent(vtracks) : l10n.callValueNo,
);
final w = renderer.value.width.toInt();
final h = renderer.value.height.toInt();
add(l10n.callInfoVideoSize, (w > 0 && h > 0) ? '$w×$h' : '—');
add(
l10n.callInfoFrameRendering,
renderer.renderVideo ? l10n.callValueYes : l10n.callValueNo,
);
final badges = <Widget>[
_badge(cs, Symbols.lock, l10n.callBadgeEncrypted),
_badge(cs, Symbols.call, l10n.callBadgeAudio),
if (info?.record == true)
_badge(cs, Symbols.radio_button_checked, l10n.callBadgeRecording),
if (info?.denoise == true)
_badge(cs, Symbols.noise_control_on, l10n.callBadgeNoiseSuppression),
if (info?.animoji == true)
_badge(cs, Symbols.mood, l10n.callBadgeAnimoji),
];
return SafeArea(
top: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.callInfoTitle,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
fontFamily: displayFontOf(context),
),
),
const SizedBox(height: 2),
Text(
name,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 16),
Wrap(spacing: 8, runSpacing: 8, children: badges),
const SizedBox(height: 16),
if (rows.isEmpty)
Text(
l10n.callInfoNoDataYet,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
for (final r in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
r[0],
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
),
const SizedBox(width: 12),
Expanded(
child: SelectableText(
r[1],
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
);
}
String? _clientLine(CallInfo? info) {
if (info == null) return null;
final engine = info.peerEngine;
if (engine == null || engine == 'неизвестно') return null;
return engine;
}
String _shortFp(String fp) => fp.length > 34 ? '${fp.substring(0, 34)}…' : fp;
Widget _badge(ColorScheme cs, IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: cs.onSurfaceVariant, fill: 1),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}