- Calls keep going with the app in the background: a foreground service (CallService.kt, driven from lib/matrix/call_service.dart) declares the microphone, the camera while it's on and the screen while it's shared. Its notification (CallStyle) shows the call's length, opens the call and hangs up. Swiping the app away ends the call. - Screen sharing on Android: consent, then media projection on the service, then LiveKit publishes a screen-share track. A shared screen replaces the sharer's camera on their tile, shown whole and unmirrored; stopping it from Android's controls stops the share. - Read receipts per thread (MSC3771): an open thread sends receipts with thread_id, "Read" under replies comes from receipts in the thread, and threads with unread replies get a dot on "N replies" and in the list. Not tested on a device yet. README updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
554 lines
17 KiB
Dart
554 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import '../matrix/calls.dart';
|
|
import '../state.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/avatar.dart';
|
|
|
|
/// The call this device is in: the other person large and you in the
|
|
/// corner for a call of two, a grid of tiles for more (a shared screen in
|
|
/// place of its sharer's camera); mute, camera, flip, share the screen and
|
|
/// leave. "Calling…" while it rings the other person.
|
|
class CallScreen extends ConsumerStatefulWidget {
|
|
const CallScreen({super.key});
|
|
|
|
static Route<void> route() => MaterialPageRoute(
|
|
settings: const RouteSettings(name: 'call'),
|
|
builder: (_) => const CallScreen(),
|
|
);
|
|
|
|
@override
|
|
ConsumerState<CallScreen> createState() => _CallScreenState();
|
|
}
|
|
|
|
class _CallScreenState extends ConsumerState<CallScreen> {
|
|
Timer? _clock;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_clock = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_clock?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final calls = ref.watch(callsProvider);
|
|
final client = ref.watch(clientProvider);
|
|
return ListenableBuilder(
|
|
listenable: calls,
|
|
builder: (context, _) {
|
|
final roomId = calls.roomId;
|
|
if (roomId == null) {
|
|
// The call ended: leave the screen.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
return const Scaffold(backgroundColor: Colors.black);
|
|
}
|
|
final room = client.getRoomById(roomId);
|
|
final tiles = _tiles(calls, room);
|
|
final title = room?.getLocalizedDisplayname() ?? '';
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0F1213),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_TopBar(title: title, calls: calls),
|
|
Expanded(
|
|
child: _Stage(tiles: tiles, calls: calls, room: room),
|
|
),
|
|
_Controls(calls: calls),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<_Tile> _tiles(Calls calls, Room? room) {
|
|
final lkRoom = calls.livekit;
|
|
if (lkRoom == null) return [];
|
|
final speakers = lkRoom.activeSpeakers.map((p) => p.identity).toSet();
|
|
_Tile tile(lk.Participant p, bool local) {
|
|
final userId = _identityUser(p.identity);
|
|
final user = room?.unsafeGetUserFromMemoryOrFallback(userId);
|
|
final camera = p.getTrackPublicationBySource(lk.TrackSource.camera);
|
|
final screen = p.getTrackPublicationBySource(
|
|
lk.TrackSource.screenShareVideo,
|
|
);
|
|
final mic = p.getTrackPublicationBySource(lk.TrackSource.microphone);
|
|
lk.VideoTrack? video(lk.TrackPublication? pub) =>
|
|
pub != null && !pub.muted && pub.track is lk.VideoTrack
|
|
? pub.track as lk.VideoTrack
|
|
: null;
|
|
return _Tile(
|
|
identity: p.identity,
|
|
userId: userId,
|
|
name: user?.calcDisplayname() ?? userId,
|
|
avatar: user?.avatarUrl,
|
|
local: local,
|
|
video: video(screen) ?? video(camera),
|
|
screen: video(screen) != null,
|
|
micMuted: mic == null || mic.muted,
|
|
speaking: speakers.contains(p.identity),
|
|
);
|
|
}
|
|
|
|
return [
|
|
if (lkRoom.localParticipant != null) tile(lkRoom.localParticipant!, true),
|
|
for (final p in lkRoom.remoteParticipants.values) tile(p, false),
|
|
];
|
|
}
|
|
|
|
/// The user of a LiveKit identity, "@alice:example.org:DEVICE": the
|
|
/// device is after the last colon (a server name may have a port).
|
|
static String _identityUser(String identity) {
|
|
final i = identity.lastIndexOf(':');
|
|
return i > 0 ? identity.substring(0, i) : identity;
|
|
}
|
|
}
|
|
|
|
class _Tile {
|
|
const _Tile({
|
|
required this.identity,
|
|
required this.userId,
|
|
required this.name,
|
|
required this.avatar,
|
|
required this.local,
|
|
required this.video,
|
|
required this.screen,
|
|
required this.micMuted,
|
|
required this.speaking,
|
|
});
|
|
|
|
final String identity;
|
|
final String userId;
|
|
final String name;
|
|
final Uri? avatar;
|
|
final bool local;
|
|
final lk.VideoTrack? video;
|
|
|
|
/// [video] is a shared screen: shown whole, never mirrored.
|
|
final bool screen;
|
|
final bool micMuted;
|
|
final bool speaking;
|
|
}
|
|
|
|
class _TopBar extends StatelessWidget {
|
|
const _TopBar({required this.title, required this.calls});
|
|
final String title;
|
|
final Calls calls;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final since = calls.joinedAt;
|
|
final status = switch (calls.phase) {
|
|
CallPhase.joining => 'Connecting…',
|
|
CallPhase.reconnecting => 'Reconnecting…',
|
|
_ when calls.ringingOut => 'Calling…',
|
|
_ when since != null => _length(DateTime.now().difference(since)),
|
|
_ => '',
|
|
};
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 4, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Back to the chat',
|
|
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontFamily: fontDisplay,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
status,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (calls.encrypted)
|
|
const Tooltip(
|
|
message: 'End-to-end encrypted',
|
|
child: Icon(Icons.lock_outline, color: Colors.white70, size: 18),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _length(Duration d) {
|
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s';
|
|
}
|
|
}
|
|
|
|
class _Stage extends StatelessWidget {
|
|
const _Stage({required this.tiles, required this.calls, required this.room});
|
|
final List<_Tile> tiles;
|
|
final Calls calls;
|
|
final Room? room;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (tiles.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final remote = tiles.where((t) => !t.local).toList();
|
|
final local = tiles.firstWhere((t) => t.local, orElse: () => tiles.first);
|
|
|
|
// Alone: ringing, or waiting for someone to come.
|
|
if (remote.isEmpty) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(child: _TileView(tile: local, large: true)),
|
|
if (calls.ringingOut)
|
|
const Align(
|
|
alignment: Alignment(0, 0.6),
|
|
child: Text(
|
|
'Ringing…',
|
|
style: TextStyle(color: Colors.white70, fontSize: 15),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
// Two: the other person large, you in the corner.
|
|
if (remote.length == 1) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(child: _TileView(tile: remote.single, large: true)),
|
|
Positioned(
|
|
right: 16,
|
|
bottom: 16,
|
|
width: 110,
|
|
height: 160,
|
|
child: _TileView(tile: local),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
// More: a grid.
|
|
final columns = tiles.length <= 4 ? 2 : 3;
|
|
return GridView.count(
|
|
padding: const EdgeInsets.all(8),
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 3 / 4,
|
|
children: [for (final t in tiles) _TileView(tile: t)],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TileView extends StatelessWidget {
|
|
const _TileView({required this.tile, this.large = false});
|
|
final _Tile tile;
|
|
final bool large;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final video = tile.video;
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(large ? 0 : 14),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1B2022),
|
|
border: tile.speaking && !large
|
|
? Border.all(color: const Color(0xFF22C3A6), width: 3)
|
|
: null,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: video != null
|
|
? lk.VideoTrackRenderer(
|
|
video,
|
|
fit: tile.screen
|
|
? lk.VideoViewFit.contain
|
|
: lk.VideoViewFit.cover,
|
|
mirrorMode: tile.local && !tile.screen
|
|
? lk.VideoViewMirrorMode.mirror
|
|
: lk.VideoViewMirrorMode.off,
|
|
)
|
|
: Center(
|
|
child: Avatar(
|
|
name: tile.name,
|
|
seed: tile.userId,
|
|
mxc: tile.avatar,
|
|
size: large ? 112 : 56,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 8,
|
|
bottom: 8,
|
|
right: 8,
|
|
child: Row(
|
|
children: [
|
|
if (tile.micMuted)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 4),
|
|
child: Icon(Icons.mic_off, color: Colors.white, size: 16),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
tile.local ? 'You' : tile.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
shadows: [Shadow(blurRadius: 4)],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Controls extends StatelessWidget {
|
|
const _Controls({required this.calls});
|
|
final Calls calls;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget round(
|
|
IconData icon,
|
|
String label,
|
|
VoidCallback? onTap, {
|
|
bool active = true,
|
|
Color? colour,
|
|
}) => Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Material(
|
|
color: colour ?? (active ? Colors.white24 : Colors.white),
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: SizedBox.square(
|
|
dimension: 56,
|
|
child: Icon(
|
|
icon,
|
|
color: colour != null || active ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final connected = calls.phase == CallPhase.connected;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
|
|
child: Row(
|
|
children: [
|
|
round(
|
|
calls.micOn ? Icons.mic : Icons.mic_off,
|
|
calls.micOn ? 'Mute' : 'Unmute',
|
|
connected ? calls.toggleMic : null,
|
|
active: calls.micOn,
|
|
),
|
|
round(
|
|
calls.cameraOn ? Icons.videocam : Icons.videocam_off,
|
|
'Camera',
|
|
connected ? calls.toggleCamera : null,
|
|
active: calls.cameraOn,
|
|
),
|
|
if (calls.cameraOn)
|
|
round(
|
|
Icons.flip_camera_android,
|
|
'Flip',
|
|
connected ? calls.flipCamera : null,
|
|
),
|
|
if (calls.canShareScreen)
|
|
round(
|
|
calls.screenSharing
|
|
? Icons.stop_screen_share
|
|
: Icons.screen_share,
|
|
'Screen',
|
|
connected ? calls.toggleScreenShare : null,
|
|
active: !calls.screenSharing,
|
|
),
|
|
round(
|
|
Icons.call_end,
|
|
'Leave',
|
|
calls.leave,
|
|
colour: const Color(0xFFD64545),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The incoming call card: who, from which room, answer (with or without
|
|
/// video) or decline.
|
|
Future<void> showIncomingCall(
|
|
BuildContext context,
|
|
Calls calls,
|
|
NavigatorState navigator,
|
|
) => showModalBottomSheet<void>(
|
|
context: context,
|
|
isDismissible: false,
|
|
enableDrag: false,
|
|
builder: (sheet) => ListenableBuilder(
|
|
listenable: calls,
|
|
builder: (context, _) {
|
|
final call = calls.incoming;
|
|
if (call == null) {
|
|
// Answered elsewhere, declined, or it stopped ringing.
|
|
// Only while the sheet is on top: after Answer, the call screen
|
|
// is, and popping would close it instead.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final route = ModalRoute.of(sheet);
|
|
if (route != null && route.isCurrent) Navigator.of(sheet).pop();
|
|
});
|
|
return const SizedBox(height: 200);
|
|
}
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Avatar(
|
|
name: call.callerName,
|
|
seed: call.callerId,
|
|
mxc: call.callerAvatar,
|
|
size: 72,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(call.callerName, style: text.titleLarge),
|
|
Text(
|
|
call.video ? 'Video call' : 'Voice call',
|
|
style: text.bodySmall,
|
|
),
|
|
if (call.roomName != call.callerName)
|
|
Text(call.roomName, style: text.bodySmall),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_RoundButton(
|
|
icon: Icons.call_end,
|
|
label: 'Decline',
|
|
colour: c.danger,
|
|
onTap: () {
|
|
Navigator.of(sheet).pop();
|
|
calls.decline();
|
|
},
|
|
),
|
|
if (call.video)
|
|
_RoundButton(
|
|
icon: Icons.call,
|
|
label: 'Voice only',
|
|
colour: c.tealStrong,
|
|
onTap: () {
|
|
Navigator.of(sheet).pop();
|
|
calls.answer(withVideo: false);
|
|
navigator.push(CallScreen.route());
|
|
},
|
|
),
|
|
_RoundButton(
|
|
icon: call.video ? Icons.videocam : Icons.call,
|
|
label: 'Answer',
|
|
colour: c.tealStrong,
|
|
onTap: () {
|
|
Navigator.of(sheet).pop();
|
|
calls.answer();
|
|
navigator.push(CallScreen.route());
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
class _RoundButton extends StatelessWidget {
|
|
const _RoundButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.colour,
|
|
required this.onTap,
|
|
});
|
|
final IconData icon;
|
|
final String label;
|
|
final Color colour;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
children: [
|
|
Material(
|
|
color: colour,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: SizedBox.square(
|
|
dimension: 64,
|
|
child: Icon(icon, color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
|
],
|
|
);
|
|
}
|