diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index a7aab36..82290b3 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -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.", diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 0024315..68a60f7 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -86,7 +86,10 @@ "chatCallScreenOff": "停止屏幕分享", "chatCallChangeSpeaker": "切换扬声器", "chatCallOngoing": "一则通话正在进行中", + "chatCallOngoingShort": "进行中", "chatCallJoin": "加入", + "chatCallDisconnect": "断开连接", + "chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。", "chatMessagePlaceholder": "发条消息……", "chatMessageEditNotify": "你正在编辑信息中……", "chatMessageReplyNotify": "你正在回复消息中……", diff --git a/lib/main.dart b/lib/main.dart index 9ff2c74..244804b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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()), + ], + ), ); }, ); diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index bea2ba2..5beb608 100755 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -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 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 signin(BuildContext context, String username, String password) async { diff --git a/lib/providers/chat.dart b/lib/providers/chat.dart index f00fcdd..755c825 100644 --- a/lib/providers/chat.dart +++ b/lib/providers/chat.dart @@ -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 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 audioInputs = []; + List 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 listener; + + List 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 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(); + 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((event) async { + if (event.reason != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Call disconnected... ${event.reason}'), + )); + } + onDispose(); + }) + ..on((event) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((event) { + sortParticipants(); + }) + ..on((event) async { + if (!room.canPlaybackAudio) { + bool? yesno = await context.showPlayAudioManuallyDialog(); + if (yesno == true) { + await room.startAudio(); + } + } + }); + } + + void sortParticipants() { + Map 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 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 setEnableVideo(value) async { + enableVideo = value; + if (!enableVideo) { + await videoTrack?.stop(); + videoTrack = null; + } else { + await changeLocalVideoTrack(); + } + onUpdate(); + } + + Future setEnableAudio(value) async { + enableAudio = value; + if (!enableAudio) { + await audioTrack?.stop(); + audioTrack = null; + } else { + await changeLocalAudioTrack(); + } + + onUpdate(); + } + + Future 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 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(); + } } diff --git a/lib/providers/navigation.dart b/lib/providers/navigation.dart index 869413f..15bd4a5 100644 --- a/lib/providers/navigation.dart +++ b/lib/providers/navigation.dart @@ -1,3 +1,3 @@ class NavigationProvider { int selectedIndex = 0; -} \ No newline at end of file +} diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index 9357324..be44d31 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -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 { - String? _token; - String? _endpoint; + bool _isHandled = false; - bool _isMounted = false; + late ChatProvider _chat; - StreamSubscription? _subscription; - List _audioInputs = []; - List _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 _callListener; - - List _participantTracks = []; - ParticipantTrack? _focusParticipant; - - Future 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(); - 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((event) async { - if (event.reason != null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Call disconnected... ${event.reason}'), - )); - } - if (router.canPop()) router.pop(); - }) - ..on((event) => sortParticipants()) - ..on((_) => sortParticipants()) - ..on((_) => sortParticipants()) - ..on((_) => sortParticipants()) - ..on((_) => sortParticipants()) - ..on((event) { - sortParticipants(); - }) - ..on((event) async { - if (!_callRoom.canPlaybackAudio) { - bool? yesno = await context.showPlayAudioManuallyDialog(); - if (yesno == true) { - await _callRoom.startAudio(); - } - } - }); - } - - void sortParticipants() { - Map 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 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 setEnableVideo(value) async { - _enableVideo = value; - if (!_enableVideo) { - await _videoTrack?.stop(); - _videoTrack = null; - } else { - await changeLocalVideoTrack(); - } - setState(() {}); - } - - Future setEnableAudio(value) async { - _enableAudio = value; - if (!_enableAudio) { - await _audioTrack?.stop(); - _audioTrack = null; - } else { - await changeLocalAudioTrack(); - } - setState(() {}); - } - - Future 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 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(); + 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 { 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 { 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 { 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 { ], ); }, - ), + ); + } + + 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 { diff --git a/lib/widgets/chat/call/call_overlay.dart b/lib/widgets/chat/call/call_overlay.dart new file mode 100644 index 0000000..e5e637f --- /dev/null +++ b/lib/widgets/chat/call/call_overlay.dart @@ -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(); + + 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}, + ); + }, + ); + } +} diff --git a/lib/widgets/chat/call/controls.dart b/lib/widgets/chat/call/controls.dart index 1a0c817..2a37654 100644 --- a/lib/widgets/chat/call/controls.dart +++ b/lib/widgets/chat/call/controls.dart @@ -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 { bool get isMuted => participant.isMuted; + void disconnect() async { + if (await context.showDisconnectDialog() != true) return; + + final chat = context.read(); + if (chat.call != null) { + chat.call!.deactivate(); + chat.call!.dispose(); + router.pop(); + } + } + void disableAudio() async { await participant.setMicrophoneEnabled(false); } - Future enableAudio() async { + void enableAudio() async { await participant.setMicrophoneEnabled(true); } @@ -207,11 +222,17 @@ class _ControlsWidgetState extends State { 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 { 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( - icon: const Icon(Icons.volume_up), - itemBuilder: (BuildContext context) { - return [ - const PopupMenuItem( - value: null, - child: ListTile( - leading: Icon(Icons.speaker), - title: Text('Select Audio Output'), - ), - ), - if (_audioOutputs != null) - ..._audioOutputs!.map((device) { - return PopupMenuItem( - 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( icon: const Icon(Icons.videocam_sharp), @@ -317,22 +304,61 @@ class _ControlsWidgetState extends State { 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( + icon: const Icon(Icons.volume_up), + itemBuilder: (BuildContext context) { + return [ + const PopupMenuItem( + value: null, + child: ListTile( + leading: Icon(Icons.speaker), + title: Text('Select Audio Output'), + ), + ), + if (_audioOutputs != null) + ..._audioOutputs!.map((device) { + return PopupMenuItem( + 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, ), diff --git a/lib/widgets/chat/call/exts.dart b/lib/widgets/chat/call/exts.dart index e4f540e..86c607a 100644 --- a/lib/widgets/chat/call/exts.dart +++ b/lib/widgets/chat/call/exts.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; extension SolianCallExt on BuildContext { Future showPlayAudioManuallyDialog() => showDialog( @@ -24,16 +25,16 @@ extension SolianCallExt on BuildContext { Future showDisconnectDialog() => showDialog( 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), ), ], ), diff --git a/lib/widgets/common_wrapper.dart b/lib/widgets/common_wrapper.dart index 024ce5f..9821810 100644 --- a/lib/widgets/common_wrapper.dart +++ b/lib/widgets/common_wrapper.dart @@ -5,7 +5,7 @@ class LayoutWrapper extends StatelessWidget { final Widget? child; final Widget? floatingActionButton; final List? 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), ); } } diff --git a/lib/widgets/exts.dart b/lib/widgets/exts.dart index 7d60d0d..cd80d0a 100644 --- a/lib/widgets/exts.dart +++ b/lib/widgets/exts.dart @@ -3,16 +3,16 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; extension SolianCommonExtensions on BuildContext { Future showErrorDialog(dynamic exception) => showDialog( - 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), - ) - ], - ), - ); -} \ No newline at end of file + 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), + ) + ], + ), + ); +} diff --git a/lib/widgets/indent_wrapper.dart b/lib/widgets/indent_wrapper.dart index 8bc44b5..b9533bc 100644 --- a/lib/widgets/indent_wrapper.dart +++ b/lib/widgets/indent_wrapper.dart @@ -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), ); } } diff --git a/lib/widgets/posts/content/attachment.dart b/lib/widgets/posts/content/attachment.dart index d44b262..08accd5 100644 --- a/lib/widgets/posts/content/attachment.dart +++ b/lib/widgets/posts/content/attachment.dart @@ -62,7 +62,9 @@ class _AttachmentItemState extends State { : Positioned( right: 12, bottom: 8, - child: Chip(label: Text(widget.badge!)), + child: Material( + child: Chip(label: Text(widget.badge!)), + ), ) ], ), diff --git a/pubspec.lock b/pubspec.lock index 1da5536..21490df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 2990085..ff44fea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: