Floating call widgets

This commit is contained in:
LittleSheep 2024-04-30 20:31:54 +08:00
parent 5922d325e5
commit 7fb94eeafa
16 changed files with 643 additions and 433 deletions

View File

@ -78,6 +78,7 @@
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
"chatCall": "Call", "chatCall": "Call",
"chatCallOngoing": "A call is ongoing", "chatCallOngoing": "A call is ongoing",
"chatCallOngoingShort": "Ongoing",
"chatCallJoin": "Join", "chatCallJoin": "Join",
"chatCallMute": "Mute", "chatCallMute": "Mute",
"chatCallUnMute": "Un-mute", "chatCallUnMute": "Un-mute",
@ -86,6 +87,8 @@
"chatCallVideoFlip": "Flip Camera", "chatCallVideoFlip": "Flip Camera",
"chatCallScreenOn": "Start Screen Share", "chatCallScreenOn": "Start Screen Share",
"chatCallScreenOff": "Stop Screen Share", "chatCallScreenOff": "Stop Screen Share",
"chatCallDisconnect": "Disconnect",
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
"chatCallChangeSpeaker": "Change Speaker", "chatCallChangeSpeaker": "Change Speaker",
"chatMessagePlaceholder": "Write a message...", "chatMessagePlaceholder": "Write a message...",
"chatMessageEditNotify": "You are about editing a message.", "chatMessageEditNotify": "You are about editing a message.",

View File

@ -86,7 +86,10 @@
"chatCallScreenOff": "停止屏幕分享", "chatCallScreenOff": "停止屏幕分享",
"chatCallChangeSpeaker": "切换扬声器", "chatCallChangeSpeaker": "切换扬声器",
"chatCallOngoing": "一则通话正在进行中", "chatCallOngoing": "一则通话正在进行中",
"chatCallOngoingShort": "进行中",
"chatCallJoin": "加入", "chatCallJoin": "加入",
"chatCallDisconnect": "断开连接",
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
"chatMessagePlaceholder": "发条消息……", "chatMessagePlaceholder": "发条消息……",
"chatMessageEditNotify": "你正在编辑信息中……", "chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……", "chatMessageReplyNotify": "你正在回复消息中……",

View File

@ -9,6 +9,7 @@ import 'package:solian/router.dart';
import 'package:solian/utils/timeago.dart'; import 'package:solian/utils/timeago.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/video_player.dart'; import 'package:solian/utils/video_player.dart';
import 'package:solian/widgets/chat/call/call_overlay.dart';
import 'package:solian/widgets/notification_notifier.dart'; import 'package:solian/widgets/notification_notifier.dart';
void main() { void main() {
@ -36,21 +37,22 @@ class SolianApp extends StatelessWidget {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
routerConfig: router, routerConfig: router,
builder: (context, child) { builder: (context, child) {
return Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider(create: (_) => NavigationProvider()), Provider(create: (_) => NavigationProvider()),
Provider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()),
Provider(create: (_) => ChatProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => NotifyProvider()), ChangeNotifierProvider(create: (_) => NotifyProvider()),
ChangeNotifierProvider(create: (_) => FriendProvider()), ChangeNotifierProvider(create: (_) => FriendProvider()),
], ],
child: NotificationNotifier(child: child ?? Container()), child: Overlay(
); initialEntries: [
}) OverlayEntry(builder: (context) {
return NotificationNotifier(child: child ?? Container());
}),
OverlayEntry(builder: (context) => const CallOverlay()),
], ],
),
); );
}, },
); );

View File

@ -5,7 +5,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:oauth2/oauth2.dart' as oauth2; import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
class AuthProvider { class AuthProvider extends ChangeNotifier {
AuthProvider(); AuthProvider();
final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe'); final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe');
@ -63,6 +63,7 @@ class AuthProvider {
var userinfo = await client!.get(userinfoEndpoint); var userinfo = await client!.get(userinfoEndpoint);
storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes)); storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes));
} }
notifyListeners();
} }
Future<void> refreshToken() async { Future<void> refreshToken() async {
@ -72,6 +73,7 @@ class AuthProvider {
client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret); client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret);
storage.write(key: storageKey, value: credentials.toJson()); storage.write(key: storageKey, value: credentials.toJson());
} }
notifyListeners();
} }
Future<void> signin(BuildContext context, String username, String password) async { Future<void> signin(BuildContext context, String username, String password) async {

View File

@ -1,11 +1,25 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/exts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatProvider { class ChatProvider extends ChangeNotifier {
bool isOpened = false; bool isOpened = false;
bool isShown = false;
ChatCallInstance? call;
Future<WebSocketChannel?> connect(AuthProvider auth) async { Future<WebSocketChannel?> connect(AuthProvider auth) async {
if (auth.client == null) await auth.pickClient(); if (auth.client == null) await auth.pickClient();
@ -26,4 +40,388 @@ class ChatProvider {
return channel; return channel;
} }
bool handleCall(Call call, Channel channel, {Function? onUpdate, Function? onDispose}) {
if (this.call != null) return false;
this.call = ChatCallInstance(
onUpdate: () {
notifyListeners();
if (onUpdate != null) onUpdate();
},
onDispose: () {
this.call = null;
notifyListeners();
if (onDispose != null) onDispose();
},
channel: channel,
info: call,
);
return true;
}
void setShown(bool state) {
isShown = state;
notifyListeners();
}
}
class ChatCallInstance {
final Function onUpdate;
final Function onDispose;
final Call info;
final Channel channel;
bool isMounted = false;
String? token;
String? endpoint;
StreamSubscription? subscription;
List<MediaDevice> audioInputs = [];
List<MediaDevice> videoInputs = [];
bool enableAudio = true;
bool enableVideo = false;
LocalAudioTrack? audioTrack;
LocalVideoTrack? videoTrack;
MediaDevice? videoDevice;
MediaDevice? audioDevice;
final VideoParameters videoParameters = VideoParametersPresets.h720_169;
late Room room;
late EventsListener<RoomEvent> listener;
List<ParticipantTrack> participantTracks = [];
ParticipantTrack? focusTrack;
ChatCallInstance({
required this.onUpdate,
required this.onDispose,
required this.channel,
required this.info,
});
void init() {
subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
room = Room();
listener = room.createListener();
Hardware.instance.enumerateDevices().then(revertDevices);
WakelockPlus.enable();
}
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return;
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
Future<(String, String)> exchangeToken(BuildContext context) async {
await checkPermissions();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
onDispose();
throw Exception("unauthorized");
}
var uri = getRequestUri('messaging', '/api/channels/${channel.alias}/calls/ongoing/token');
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
token = result['token'];
endpoint = 'wss://${result['endpoint']}';
joinRoom(context, endpoint!, token!);
return (token!, endpoint!);
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
}
}
void joinRoom(BuildContext context, String url, String token) async {
if (isMounted) {
return;
} else {
isMounted = true;
}
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
try {
await room.connect(
url,
token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: const VideoPublishOptions(
name: 'callvideo',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensionsPresets.h1080_169,
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
),
),
defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: videoParameters),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: audioTrack),
camera: TrackOption(track: videoTrack),
),
);
setupRoom(context);
} catch (e) {
context.showErrorDialog(e);
} finally {
notify.close();
}
}
void autoPublish(BuildContext context) async {
try {
if (enableVideo) await room.localParticipant?.setCameraEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
try {
if (enableAudio) await room.localParticipant?.setMicrophoneEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
}
void setupRoom(BuildContext context) {
room.addListener(onRoomDidUpdate);
setupRoomListeners(context);
sortParticipants();
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish(context));
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
}
void setupRoomListeners(BuildContext context) {
listener
..on<RoomDisconnectedEvent>((event) async {
if (event.reason != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Call disconnected... ${event.reason}'),
));
}
onDispose();
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
})
..on<AudioPlaybackStatusChanged>((event) async {
if (!room.canPlaybackAudio) {
bool? yesno = await context.showPlayAudioManuallyDialog();
if (yesno == true) {
await room.startAudio();
}
}
});
}
void sortParticipants() {
Map<String, ParticipantTrack> mediaTracks = {};
for (var participant in room.remoteParticipants.values) {
mediaTracks[participant.sid] = ParticipantTrack(
participant: participant,
videoTrack: null,
isScreenShare: false,
);
for (var t in participant.videoTrackPublications) {
mediaTracks[participant.sid]?.videoTrack = t.track;
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
}
}
final mediaTrackList = mediaTracks.values.toList();
mediaTrackList.sort((a, b) {
// Loudest people first
if (a.participant.isSpeaking && b.participant.isSpeaking) {
if (a.participant.audioLevel > b.participant.audioLevel) {
return -1;
} else {
return 1;
}
}
// Last spoke first
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// Has video first
if (a.participant.hasVideo != b.participant.hasVideo) {
return a.participant.hasVideo ? -1 : 1;
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
});
ParticipantTrack localTrack = ParticipantTrack(
participant: room.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
if (room.localParticipant != null) {
final localParticipantTracks = room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
}
participantTracks = [localTrack, ...mediaTrackList];
if (focusTrack == null) {
focusTrack = participantTracks.first;
} else {
final idx = participantTracks.indexWhere((x) => focusTrack!.participant.sid == x.participant.sid);
focusTrack = participantTracks[idx];
}
onUpdate();
}
void revertDevices(List<MediaDevice> devices) async {
audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
if (audioInputs.isNotEmpty) {
if (audioDevice == null && enableAudio) {
audioDevice = audioInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalAudioTrack();
onUpdate();
});
}
}
if (videoInputs.isNotEmpty) {
if (videoDevice == null && enableVideo) {
videoDevice = videoInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalVideoTrack();
onUpdate();
});
}
}
onUpdate();
}
Future<void> setEnableVideo(value) async {
enableVideo = value;
if (!enableVideo) {
await videoTrack?.stop();
videoTrack = null;
} else {
await changeLocalVideoTrack();
}
onUpdate();
}
Future<void> setEnableAudio(value) async {
enableAudio = value;
if (!enableAudio) {
await audioTrack?.stop();
audioTrack = null;
} else {
await changeLocalAudioTrack();
}
onUpdate();
}
Future<void> changeLocalAudioTrack() async {
if (audioTrack != null) {
await audioTrack!.stop();
audioTrack = null;
}
if (audioDevice != null) {
audioTrack = await LocalAudioTrack.create(AudioCaptureOptions(
deviceId: audioDevice!.deviceId,
));
await audioTrack!.start();
}
}
Future<void> changeLocalVideoTrack() async {
if (videoTrack != null) {
await videoTrack!.stop();
videoTrack = null;
}
if (videoDevice != null) {
videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions(
deviceId: videoDevice!.deviceId,
params: videoParameters,
));
await videoTrack!.start();
}
}
void changeFocusTrack(ParticipantTrack track) {
focusTrack = track;
onUpdate();
}
void onRoomDidUpdate() => sortParticipants();
void deactivate() {
subscription?.cancel();
}
void dispose() {
room.removeListener(onRoomDidUpdate);
(() async {
await listener.dispose();
await room.disconnect();
await room.dispose();
})();
WakelockPlus.disable();
onDispose();
}
} }

View File

@ -1,22 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/call/controls.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.dart';
import 'package:solian/widgets/chat/call/participant_menu.dart'; import 'package:solian/widgets/chat/call/participant_menu.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/indent_wrapper.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'dart:math' as math; import 'dart:math' as math;
class ChatCall extends StatefulWidget { class ChatCall extends StatefulWidget {
@ -29,337 +20,39 @@ class ChatCall extends StatefulWidget {
} }
class _ChatCallState extends State<ChatCall> { class _ChatCallState extends State<ChatCall> {
String? _token; bool _isHandled = false;
String? _endpoint;
bool _isMounted = false; late ChatProvider _chat;
StreamSubscription? _subscription; ChatCallInstance get _call => _chat.call!;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
final VideoParameters _videoParameters = VideoParametersPresets.h720_169;
late Room _callRoom;
late EventsListener<RoomEvent> _callListener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusParticipant;
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return;
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
Future<(String, String)> exchangeToken() async {
await checkPermissions();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
router.pop();
throw Error();
}
var uri = getRequestUri('messaging', '/api/channels/${widget.call.channel.alias}/calls/ongoing/token');
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
_token = result['token'];
_endpoint = 'wss://${result['endpoint']}';
joinRoom(_endpoint!, _token!);
return (_token!, _endpoint!);
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
}
}
void joinRoom(String url, String token) async {
if (_isMounted) {
return;
} else {
_isMounted = true;
}
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
try {
await _callRoom.connect(
url,
token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: const VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensionsPresets.h1080_169,
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
),
),
defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: _videoParameters),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: _audioTrack),
camera: TrackOption(track: _videoTrack),
),
);
setupRoom();
} catch (e) {
context.showErrorDialog(e);
} finally {
notify.close();
}
}
void autoPublish() async {
try {
if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
try {
if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
}
void setupRoom() {
_callRoom.addListener(onRoomDidUpdate);
setupRoomListeners();
sortParticipants();
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish());
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
}
void setupRoomListeners() {
_callListener
..on<RoomDisconnectedEvent>((event) async {
if (event.reason != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Call disconnected... ${event.reason}'),
));
}
if (router.canPop()) router.pop();
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
})
..on<AudioPlaybackStatusChanged>((event) async {
if (!_callRoom.canPlaybackAudio) {
bool? yesno = await context.showPlayAudioManuallyDialog();
if (yesno == true) {
await _callRoom.startAudio();
}
}
});
}
void sortParticipants() {
Map<String, ParticipantTrack> mediaTracks = {};
for (var participant in _callRoom.remoteParticipants.values) {
mediaTracks[participant.sid] = ParticipantTrack(
participant: participant,
videoTrack: null,
isScreenShare: false,
);
for (var t in participant.videoTrackPublications) {
mediaTracks[participant.sid]?.videoTrack = t.track;
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
}
}
final mediaTrackList = mediaTracks.values.toList();
mediaTrackList.sort((a, b) {
// Loudest people first
if (a.participant.isSpeaking && b.participant.isSpeaking) {
if (a.participant.audioLevel > b.participant.audioLevel) {
return -1;
} else {
return 1;
}
}
// Last spoke first
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// Has video first
if (a.participant.hasVideo != b.participant.hasVideo) {
return a.participant.hasVideo ? -1 : 1;
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
});
ParticipantTrack localTrack = ParticipantTrack(
participant: _callRoom.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
if (_callRoom.localParticipant != null) {
final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
}
setState(() {
_participantTracks = [localTrack, ...mediaTrackList];
if (_focusParticipant == null) {
_focusParticipant = _participantTracks.first;
} else {
final idx = _participantTracks.indexWhere((x) => _focusParticipant!.participant.sid == x.participant.sid);
_focusParticipant = _participantTracks[idx];
}
});
}
void onRoomDidUpdate() => sortParticipants();
void revertDevices(List<MediaDevice> devices) async {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
if (_audioInputs.isNotEmpty) {
if (_audioDevice == null && _enableAudio) {
_audioDevice = _audioInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalAudioTrack();
setState(() {});
});
}
}
if (_videoInputs.isNotEmpty) {
if (_videoDevice == null && _enableVideo) {
_videoDevice = _videoInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalVideoTrack();
setState(() {});
});
}
}
setState(() {});
}
Future<void> setEnableVideo(value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await changeLocalVideoTrack();
}
setState(() {});
}
Future<void> setEnableAudio(value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await changeLocalAudioTrack();
}
setState(() {});
}
Future<void> changeLocalAudioTrack() async {
if (_audioTrack != null) {
await _audioTrack!.stop();
_audioTrack = null;
}
if (_audioDevice != null) {
_audioTrack = await LocalAudioTrack.create(AudioCaptureOptions(
deviceId: _audioDevice!.deviceId,
));
await _audioTrack!.start();
}
}
Future<void> changeLocalVideoTrack() async {
if (_videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (_videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions(
deviceId: _videoDevice!.deviceId,
params: _videoParameters,
));
await _videoTrack!.start();
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
_callRoom = Room(); WidgetsBinding.instance.addPostFrameCallback((_) {
_callListener = _callRoom.createListener(); _chat.setShown(true);
Hardware.instance.enumerateDevices().then(revertDevices); });
WakelockPlus.enable();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( _chat = context.watch<ChatProvider>();
title: AppLocalizations.of(context)!.chatCall, if (!_isHandled) {
hideDrawer: true, _isHandled = true;
child: FutureBuilder( if (_chat.handleCall(widget.call, widget.call.channel)) {
future: exchangeToken(), _chat.call?.init();
}
}
Widget content;
if (_chat.call == null) {
content = const Center(
child: CircularProgressIndicator(),
);
} else {
content = FutureBuilder(
future: _call.exchangeToken(context),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) { if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -372,15 +65,20 @@ class _ChatCallState extends State<ChatCall> {
Expanded( Expanded(
child: Container( child: Container(
color: Theme.of(context).colorScheme.surfaceVariant, color: Theme.of(context).colorScheme.surfaceVariant,
child: _focusParticipant != null child: _call.focusTrack != null
? InteractiveParticipantWidget( ? InteractiveParticipantWidget(
participant: _focusParticipant!, isFixed: false,
participant: _call.focusTrack!,
onTap: () {}, onTap: () {},
) )
: Container(), : Container(),
), ),
), ),
if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!), if (_call.room.localParticipant != null)
ControlsWidget(
_call.room,
_call.room.localParticipant!,
),
], ],
), ),
Positioned( Positioned(
@ -391,10 +89,10 @@ class _ChatCallState extends State<ChatCall> {
height: 128, height: 128,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: math.max(0, _participantTracks.length), itemCount: math.max(0, _call.participantTracks.length),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final track = _participantTracks[index]; final track = _call.participantTracks[index];
if (track.participant.sid == _focusParticipant?.participant.sid) { if (track.participant.sid == _call.focusTrack?.participant.sid) {
return Container(); return Container();
} }
@ -403,13 +101,14 @@ class _ChatCallState extends State<ChatCall> {
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget( child: InteractiveParticipantWidget(
isFixed: true,
width: 120, width: 120,
height: 120, height: 120,
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
participant: track, participant: track,
onTap: () { onTap: () {
if (track.participant.sid != _focusParticipant?.participant.sid) { if (track.participant.sid != _call.focusTrack?.participant.sid) {
setState(() => _focusParticipant = track); _call.changeFocusTrack(track);
} }
}, },
), ),
@ -422,27 +121,21 @@ class _ChatCallState extends State<ChatCall> {
], ],
); );
}, },
), );
}
return IndentWrapper(
title: AppLocalizations.of(context)!.chatCall,
hideDrawer: true,
child: content,
); );
} }
@override @override
void deactivate() { void deactivate() {
_subscription?.cancel(); WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setShown(false));
super.deactivate(); super.deactivate();
} }
@override
void dispose() {
WakelockPlus.disable();
(() async {
_callRoom.removeListener(onRoomDidUpdate);
await _callListener.dispose();
await _callRoom.disconnect();
await _callRoom.dispose();
})();
super.dispose();
}
} }
class InteractiveParticipantWidget extends StatelessWidget { class InteractiveParticipantWidget extends StatelessWidget {

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:draggable_float_widget/draggable_float_widget.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
class CallOverlay extends StatelessWidget {
const CallOverlay({super.key});
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(8));
final chat = context.watch<ChatProvider>();
if (chat.isShown || chat.call == null) {
return Container();
}
return DraggableFloatWidget(
config: const DraggableFloatWidgetBaseConfig(
initPositionYInTop: false,
initPositionYMarginBorder: 50,
borderTopContainTopBar: true,
borderBottom: defaultBorderWidth,
borderLeft: 8,
),
child: Material(
elevation: 6,
color: Colors.transparent,
borderRadius: radius,
child: ClipRRect(
borderRadius: radius,
child: Container(
height: 80,
width: 80,
color: Theme.of(context).colorScheme.secondaryContainer,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.call, size: 18),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.chatCallOngoingShort,
style: const TextStyle(fontSize: 12),
)
],
),
),
),
),
onTap: () {
router.pushNamed(
'chat.channel.call',
extra: chat.call!.info,
pathParameters: {'channel': chat.call!.channel.alias},
);
},
);
}
}

View File

@ -6,6 +6,10 @@ import 'package:flutter_background/flutter_background.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/exts.dart'; import 'package:solian/widgets/exts.dart';
class ControlsWidget extends StatefulWidget { class ControlsWidget extends StatefulWidget {
@ -64,11 +68,22 @@ class _ControlsWidgetState extends State<ControlsWidget> {
bool get isMuted => participant.isMuted; bool get isMuted => participant.isMuted;
void disconnect() async {
if (await context.showDisconnectDialog() != true) return;
final chat = context.read<ChatProvider>();
if (chat.call != null) {
chat.call!.deactivate();
chat.call!.dispose();
router.pop();
}
}
void disableAudio() async { void disableAudio() async {
await participant.setMicrophoneEnabled(false); await participant.setMicrophoneEnabled(false);
} }
Future<void> enableAudio() async { void enableAudio() async {
await participant.setMicrophoneEnabled(true); await participant.setMicrophoneEnabled(true);
} }
@ -207,11 +222,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
spacing: 5, spacing: 5,
runSpacing: 5, runSpacing: 5,
children: [ children: [
IconButton(
icon: Transform.flip(flipX: true, child: const Icon(Icons.exit_to_app)),
color: Theme.of(context).colorScheme.onSurface,
onPressed: disconnect,
),
if (participant.isMicrophoneEnabled()) if (participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android)) if (lkPlatformIs(PlatformType.android))
IconButton( IconButton(
onPressed: disableAudio, onPressed: disableAudio,
icon: const Icon(Icons.mic), icon: const Icon(Icons.mic),
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallMute, tooltip: AppLocalizations.of(context)!.chatCallMute,
) )
else else
@ -247,43 +268,9 @@ class _ControlsWidgetState extends State<ControlsWidget> {
IconButton( IconButton(
onPressed: enableAudio, onPressed: enableAudio,
icon: const Icon(Icons.mic_off), icon: const Icon(Icons.mic_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallUnMute, tooltip: AppLocalizations.of(context)!.chatCallUnMute,
), ),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.volume_up),
itemBuilder: (BuildContext context) {
return [
const PopupMenuItem<MediaDevice>(
value: null,
child: ListTile(
leading: Icon(Icons.speaker),
title: Text('Select Audio Output'),
),
),
if (_audioOutputs != null)
..._audioOutputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioOutput(device),
);
})
];
},
),
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton(
disabledColor: Colors.grey,
onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null,
icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android),
tooltip: AppLocalizations.of(context)!.chatCallChangeSpeaker,
),
if (participant.isCameraEnabled()) if (participant.isCameraEnabled())
PopupMenuButton<MediaDevice>( PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp), icon: const Icon(Icons.videocam_sharp),
@ -317,22 +304,61 @@ class _ControlsWidgetState extends State<ControlsWidget> {
IconButton( IconButton(
onPressed: enableVideo, onPressed: enableVideo,
icon: const Icon(Icons.videocam_off), icon: const Icon(Icons.videocam_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallVideoOn, tooltip: AppLocalizations.of(context)!.chatCallVideoOn,
), ),
IconButton( IconButton(
icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front), icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => toggleCamera(), onPressed: () => toggleCamera(),
tooltip: AppLocalizations.of(context)!.chatCallVideoFlip, tooltip: AppLocalizations.of(context)!.chatCallVideoFlip,
), ),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.volume_up),
itemBuilder: (BuildContext context) {
return [
const PopupMenuItem<MediaDevice>(
value: null,
child: ListTile(
leading: Icon(Icons.speaker),
title: Text('Select Audio Output'),
),
),
if (_audioOutputs != null)
..._audioOutputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioOutput(device),
);
})
];
},
),
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton(
onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null,
color: Theme.of(context).colorScheme.onSurface,
icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android),
tooltip: AppLocalizations.of(context)!.chatCallChangeSpeaker,
),
if (participant.isScreenShareEnabled()) if (participant.isScreenShareEnabled())
IconButton( IconButton(
icon: const Icon(Icons.monitor_outlined), icon: const Icon(Icons.monitor_outlined),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => disableScreenShare(), onPressed: () => disableScreenShare(),
tooltip: AppLocalizations.of(context)!.chatCallScreenOff, tooltip: AppLocalizations.of(context)!.chatCallScreenOff,
) )
else else
IconButton( IconButton(
icon: const Icon(Icons.monitor), icon: const Icon(Icons.monitor),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => enableScreenShare(), onPressed: () => enableScreenShare(),
tooltip: AppLocalizations.of(context)!.chatCallScreenOn, tooltip: AppLocalizations.of(context)!.chatCallScreenOn,
), ),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension SolianCallExt on BuildContext { extension SolianCallExt on BuildContext {
Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>( Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>(
@ -24,16 +25,16 @@ extension SolianCallExt on BuildContext {
Future<bool?> showDisconnectDialog() => showDialog<bool>( Future<bool?> showDisconnectDialog() => showDialog<bool>(
context: this, context: this,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Disconnect'), title: Text(AppLocalizations.of(this)!.chatCallDisconnect),
content: const Text('Are you sure to disconnect?'), content: Text(AppLocalizations.of(this)!.chatCallDisconnectConfirm),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'), child: Text(AppLocalizations.of(this)!.confirmCancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
child: const Text('Disconnect'), child: Text(AppLocalizations.of(this)!.confirmOkay),
), ),
], ],
), ),

View File

@ -5,7 +5,7 @@ class LayoutWrapper extends StatelessWidget {
final Widget? child; final Widget? child;
final Widget? floatingActionButton; final Widget? floatingActionButton;
final List<Widget>? appBarActions; final List<Widget>? appBarActions;
final bool? noSafeArea; final bool noSafeArea;
final String title; final String title;
const LayoutWrapper({ const LayoutWrapper({
@ -14,7 +14,7 @@ class LayoutWrapper extends StatelessWidget {
required this.title, required this.title,
this.floatingActionButton, this.floatingActionButton,
this.appBarActions, this.appBarActions,
this.noSafeArea, this.noSafeArea = false,
}); });
@override @override
@ -25,7 +25,7 @@ class LayoutWrapper extends StatelessWidget {
appBar: AppBar(title: Text(title), actions: appBarActions), appBar: AppBar(title: Text(title), actions: appBarActions),
floatingActionButton: floatingActionButton, floatingActionButton: floatingActionButton,
drawer: const SolianNavigationDrawer(), drawer: const SolianNavigationDrawer(),
body: (noSafeArea ?? false) ? content : SafeArea(child: content), body: noSafeArea ? content : SafeArea(child: content),
); );
} }
} }

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/common_wrapper.dart'; import 'package:solian/widgets/common_wrapper.dart';
import 'package:solian/widgets/navigation_drawer.dart'; import 'package:solian/widgets/navigation_drawer.dart';
class IndentWrapper extends LayoutWrapper { class IndentWrapper extends LayoutWrapper {
final bool? hideDrawer; final bool hideDrawer;
const IndentWrapper({ const IndentWrapper({
super.key, super.key,
@ -11,8 +12,8 @@ class IndentWrapper extends LayoutWrapper {
required super.title, required super.title,
super.floatingActionButton, super.floatingActionButton,
super.appBarActions, super.appBarActions,
this.hideDrawer, this.hideDrawer = false,
super.noSafeArea, super.noSafeArea = false,
}) : super(); }) : super();
@override @override
@ -20,10 +21,17 @@ class IndentWrapper extends LayoutWrapper {
final content = child ?? Container(); final content = child ?? Container();
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(title), actions: appBarActions), appBar: AppBar(
leading: hideDrawer ? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => router.pop(),
) : null,
title: Text(title),
actions: appBarActions,
),
floatingActionButton: floatingActionButton, floatingActionButton: floatingActionButton,
drawer: (hideDrawer ?? false) ? null : const SolianNavigationDrawer(), drawer: const SolianNavigationDrawer(),
body: (noSafeArea ?? false) ? content : SafeArea(child: content), body: noSafeArea ? content : SafeArea(child: content),
); );
} }
} }

View File

@ -62,7 +62,9 @@ class _AttachmentItemState extends State<AttachmentItem> {
: Positioned( : Positioned(
right: 12, right: 12,
bottom: 8, bottom: 8,
child: Material(
child: Chip(label: Text(widget.badge!)), child: Chip(label: Text(widget.badge!)),
),
) )
], ],
), ),

View File

@ -169,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
draggable_float_widget:
dependency: "direct main"
description:
name: draggable_float_widget
sha256: "075675c56f6b2bfc9f972a3937dc1b59838489a312f75fe7e90ba6844a84dce4"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View File

@ -66,6 +66,7 @@ dependencies:
flutter_background: ^1.2.0 flutter_background: ^1.2.0
wakelock_plus: ^1.2.4 wakelock_plus: ^1.2.4
flutter_local_notifications: ^17.1.0 flutter_local_notifications: ^17.1.0
draggable_float_widget: ^0.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: