✨ Floating call widgets
This commit is contained in:
parent
5922d325e5
commit
7fb94eeafa
@ -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!",
|
||||
"chatCall": "Call",
|
||||
"chatCallOngoing": "A call is ongoing",
|
||||
"chatCallOngoingShort": "Ongoing",
|
||||
"chatCallJoin": "Join",
|
||||
"chatCallMute": "Mute",
|
||||
"chatCallUnMute": "Un-mute",
|
||||
@ -86,6 +87,8 @@
|
||||
"chatCallVideoFlip": "Flip Camera",
|
||||
"chatCallScreenOn": "Start 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",
|
||||
"chatMessagePlaceholder": "Write a message...",
|
||||
"chatMessageEditNotify": "You are about editing a message.",
|
||||
|
@ -86,7 +86,10 @@
|
||||
"chatCallScreenOff": "停止屏幕分享",
|
||||
"chatCallChangeSpeaker": "切换扬声器",
|
||||
"chatCallOngoing": "一则通话正在进行中",
|
||||
"chatCallOngoingShort": "进行中",
|
||||
"chatCallJoin": "加入",
|
||||
"chatCallDisconnect": "断开连接",
|
||||
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
|
||||
"chatMessagePlaceholder": "发条消息……",
|
||||
"chatMessageEditNotify": "你正在编辑信息中……",
|
||||
"chatMessageReplyNotify": "你正在回复消息中……",
|
||||
|
@ -9,6 +9,7 @@ import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/timeago.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/utils/video_player.dart';
|
||||
import 'package:solian/widgets/chat/call/call_overlay.dart';
|
||||
import 'package:solian/widgets/notification_notifier.dart';
|
||||
|
||||
void main() {
|
||||
@ -36,21 +37,22 @@ class SolianApp extends StatelessWidget {
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
routerConfig: router,
|
||||
builder: (context, child) {
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => NavigationProvider()),
|
||||
Provider(create: (_) => AuthProvider()),
|
||||
Provider(create: (_) => ChatProvider()),
|
||||
ChangeNotifierProvider(create: (_) => NotifyProvider()),
|
||||
ChangeNotifierProvider(create: (_) => FriendProvider()),
|
||||
],
|
||||
child: NotificationNotifier(child: child ?? Container()),
|
||||
);
|
||||
})
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => NavigationProvider()),
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ChatProvider()),
|
||||
ChangeNotifierProvider(create: (_) => NotifyProvider()),
|
||||
ChangeNotifierProvider(create: (_) => FriendProvider()),
|
||||
],
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return NotificationNotifier(child: child ?? Container());
|
||||
}),
|
||||
OverlayEntry(builder: (context) => const CallOverlay()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:oauth2/oauth2.dart' as oauth2;
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
|
||||
class AuthProvider {
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
AuthProvider();
|
||||
|
||||
final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe');
|
||||
@ -63,6 +63,7 @@ class AuthProvider {
|
||||
var userinfo = await client!.get(userinfoEndpoint);
|
||||
storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshToken() async {
|
||||
@ -72,6 +73,7 @@ class AuthProvider {
|
||||
client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret);
|
||||
storage.write(key: storageKey, value: credentials.toJson());
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signin(BuildContext context, String username, String password) async {
|
||||
|
@ -1,11 +1,25 @@
|
||||
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/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:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ChatProvider {
|
||||
class ChatProvider extends ChangeNotifier {
|
||||
bool isOpened = false;
|
||||
bool isShown = false;
|
||||
|
||||
ChatCallInstance? call;
|
||||
|
||||
Future<WebSocketChannel?> connect(AuthProvider auth) async {
|
||||
if (auth.client == null) await auth.pickClient();
|
||||
@ -26,4 +40,388 @@ class ChatProvider {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
class NavigationProvider {
|
||||
int selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/call.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/widgets/chat/call/controls.dart';
|
||||
import 'package:solian/widgets/chat/call/exts.dart';
|
||||
import 'package:solian/widgets/chat/call/participant.dart';
|
||||
import 'package:solian/widgets/chat/call/participant_menu.dart';
|
||||
import 'package:solian/widgets/exts.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:wakelock_plus/wakelock_plus.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class ChatCall extends StatefulWidget {
|
||||
@ -29,337 +20,39 @@ class ChatCall extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatCallState extends State<ChatCall> {
|
||||
String? _token;
|
||||
String? _endpoint;
|
||||
bool _isHandled = false;
|
||||
|
||||
bool _isMounted = false;
|
||||
late ChatProvider _chat;
|
||||
|
||||
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 _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();
|
||||
}
|
||||
}
|
||||
ChatCallInstance get _call => _chat.call!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
|
||||
_callRoom = Room();
|
||||
_callListener = _callRoom.createListener();
|
||||
Hardware.instance.enumerateDevices().then(revertDevices);
|
||||
WakelockPlus.enable();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_chat.setShown(true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndentWrapper(
|
||||
title: AppLocalizations.of(context)!.chatCall,
|
||||
hideDrawer: true,
|
||||
child: FutureBuilder(
|
||||
future: exchangeToken(),
|
||||
_chat = context.watch<ChatProvider>();
|
||||
if (!_isHandled) {
|
||||
_isHandled = true;
|
||||
if (_chat.handleCall(widget.call, widget.call.channel)) {
|
||||
_chat.call?.init();
|
||||
}
|
||||
}
|
||||
|
||||
Widget content;
|
||||
if (_chat.call == null) {
|
||||
content = const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
} else {
|
||||
content = FutureBuilder(
|
||||
future: _call.exchangeToken(context),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@ -372,15 +65,20 @@ class _ChatCallState extends State<ChatCall> {
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: _focusParticipant != null
|
||||
child: _call.focusTrack != null
|
||||
? InteractiveParticipantWidget(
|
||||
participant: _focusParticipant!,
|
||||
isFixed: false,
|
||||
participant: _call.focusTrack!,
|
||||
onTap: () {},
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
),
|
||||
if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!),
|
||||
if (_call.room.localParticipant != null)
|
||||
ControlsWidget(
|
||||
_call.room,
|
||||
_call.room.localParticipant!,
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
@ -391,10 +89,10 @@ class _ChatCallState extends State<ChatCall> {
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: math.max(0, _participantTracks.length),
|
||||
itemCount: math.max(0, _call.participantTracks.length),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final track = _participantTracks[index];
|
||||
if (track.participant.sid == _focusParticipant?.participant.sid) {
|
||||
final track = _call.participantTracks[index];
|
||||
if (track.participant.sid == _call.focusTrack?.participant.sid) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
@ -403,13 +101,14 @@ class _ChatCallState extends State<ChatCall> {
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: InteractiveParticipantWidget(
|
||||
isFixed: true,
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Theme.of(context).cardColor,
|
||||
participant: track,
|
||||
onTap: () {
|
||||
if (track.participant.sid != _focusParticipant?.participant.sid) {
|
||||
setState(() => _focusParticipant = track);
|
||||
if (track.participant.sid != _call.focusTrack?.participant.sid) {
|
||||
_call.changeFocusTrack(track);
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -422,27 +121,21 @@ class _ChatCallState extends State<ChatCall> {
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return IndentWrapper(
|
||||
title: AppLocalizations.of(context)!.chatCall,
|
||||
hideDrawer: true,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
_subscription?.cancel();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setShown(false));
|
||||
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 {
|
||||
|
63
lib/widgets/chat/call/call_overlay.dart
Normal file
63
lib/widgets/chat/call/call_overlay.dart
Normal 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},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -6,6 +6,10 @@ import 'package:flutter_background/flutter_background.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:livekit_client/livekit_client.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';
|
||||
|
||||
class ControlsWidget extends StatefulWidget {
|
||||
@ -64,11 +68,22 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
|
||||
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 {
|
||||
await participant.setMicrophoneEnabled(false);
|
||||
}
|
||||
|
||||
Future<void> enableAudio() async {
|
||||
void enableAudio() async {
|
||||
await participant.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
@ -207,11 +222,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
spacing: 5,
|
||||
runSpacing: 5,
|
||||
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 (lkPlatformIs(PlatformType.android))
|
||||
IconButton(
|
||||
onPressed: disableAudio,
|
||||
icon: const Icon(Icons.mic),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: AppLocalizations.of(context)!.chatCallMute,
|
||||
)
|
||||
else
|
||||
@ -247,43 +268,9 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
IconButton(
|
||||
onPressed: enableAudio,
|
||||
icon: const Icon(Icons.mic_off),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
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())
|
||||
PopupMenuButton<MediaDevice>(
|
||||
icon: const Icon(Icons.videocam_sharp),
|
||||
@ -317,22 +304,61 @@ class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
IconButton(
|
||||
onPressed: enableVideo,
|
||||
icon: const Icon(Icons.videocam_off),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: AppLocalizations.of(context)!.chatCallVideoOn,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => toggleCamera(),
|
||||
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())
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor_outlined),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => disableScreenShare(),
|
||||
tooltip: AppLocalizations.of(context)!.chatCallScreenOff,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => enableScreenShare(),
|
||||
tooltip: AppLocalizations.of(context)!.chatCallScreenOn,
|
||||
),
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
extension SolianCallExt on BuildContext {
|
||||
Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>(
|
||||
@ -24,16 +25,16 @@ extension SolianCallExt on BuildContext {
|
||||
Future<bool?> showDisconnectDialog() => showDialog<bool>(
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Are you sure to disconnect?'),
|
||||
title: Text(AppLocalizations.of(this)!.chatCallDisconnect),
|
||||
content: Text(AppLocalizations.of(this)!.chatCallDisconnectConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(AppLocalizations.of(this)!.confirmCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Disconnect'),
|
||||
child: Text(AppLocalizations.of(this)!.confirmOkay),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -5,7 +5,7 @@ class LayoutWrapper extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final Widget? floatingActionButton;
|
||||
final List<Widget>? appBarActions;
|
||||
final bool? noSafeArea;
|
||||
final bool noSafeArea;
|
||||
final String title;
|
||||
|
||||
const LayoutWrapper({
|
||||
@ -14,7 +14,7 @@ class LayoutWrapper extends StatelessWidget {
|
||||
required this.title,
|
||||
this.floatingActionButton,
|
||||
this.appBarActions,
|
||||
this.noSafeArea,
|
||||
this.noSafeArea = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -25,7 +25,7 @@ class LayoutWrapper extends StatelessWidget {
|
||||
appBar: AppBar(title: Text(title), actions: appBarActions),
|
||||
floatingActionButton: floatingActionButton,
|
||||
drawer: const SolianNavigationDrawer(),
|
||||
body: (noSafeArea ?? false) ? content : SafeArea(child: content),
|
||||
body: noSafeArea ? content : SafeArea(child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,16 +3,16 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
extension SolianCommonExtensions on BuildContext {
|
||||
Future<void> showErrorDialog(dynamic exception) => showDialog<void>(
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(this)!.errorHappened),
|
||||
content: Text(exception.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(AppLocalizations.of(this)!.confirmOkay),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(this)!.errorHappened),
|
||||
content: Text(exception.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(AppLocalizations.of(this)!.confirmOkay),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/common_wrapper.dart';
|
||||
import 'package:solian/widgets/navigation_drawer.dart';
|
||||
|
||||
class IndentWrapper extends LayoutWrapper {
|
||||
final bool? hideDrawer;
|
||||
final bool hideDrawer;
|
||||
|
||||
const IndentWrapper({
|
||||
super.key,
|
||||
@ -11,8 +12,8 @@ class IndentWrapper extends LayoutWrapper {
|
||||
required super.title,
|
||||
super.floatingActionButton,
|
||||
super.appBarActions,
|
||||
this.hideDrawer,
|
||||
super.noSafeArea,
|
||||
this.hideDrawer = false,
|
||||
super.noSafeArea = false,
|
||||
}) : super();
|
||||
|
||||
@override
|
||||
@ -20,10 +21,17 @@ class IndentWrapper extends LayoutWrapper {
|
||||
final content = child ?? Container();
|
||||
|
||||
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,
|
||||
drawer: (hideDrawer ?? false) ? null : const SolianNavigationDrawer(),
|
||||
body: (noSafeArea ?? false) ? content : SafeArea(child: content),
|
||||
drawer: const SolianNavigationDrawer(),
|
||||
body: noSafeArea ? content : SafeArea(child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -62,7 +62,9 @@ class _AttachmentItemState extends State<AttachmentItem> {
|
||||
: Positioned(
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
child: Chip(label: Text(widget.badge!)),
|
||||
child: Material(
|
||||
child: Chip(label: Text(widget.badge!)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -169,6 +169,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -66,6 +66,7 @@ dependencies:
|
||||
flutter_background: ^1.2.0
|
||||
wakelock_plus: ^1.2.4
|
||||
flutter_local_notifications: ^17.1.0
|
||||
draggable_float_widget: ^0.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Loading…
Reference in New Issue
Block a user