This commit is contained in:
LittleSheep 2024-11-24 20:23:06 +08:00
parent 66f41179ba
commit 7221af75eb
31 changed files with 2769 additions and 46 deletions

View File

@ -202,5 +202,31 @@
"one": "{} result",
"other": "{} results"
},
"postSearchTook": "Took {}"
"postSearchTook": "Took {}",
"call" : "Call",
"callOngoingNotice": "A call is ongoing",
"callJoin": "Join",
"callResume": "Resume",
"callMicrophone": "Microphone",
"callCamera": "Camera",
"callMicrophoneDisabled": "Microphone is disabled",
"callMicrophoneSelect": "Select a microphone",
"callCameraDisabled": "Camera is disabled",
"callCameraSelect": "Select a camera",
"callDisconnected": "Call has been disconnected",
"callEnded": "Call has been ended",
"callStatusConnected": "Connected",
"callStatusDisconnected": "Disconnected",
"callStatusConnecting": "Connecting",
"callStatusReconnecting": "Reconnecting",
"callDisconnect": "Disconnect",
"callDisconnectDescription": "Are you sure you want to disconnect from the call?",
"callMicrophoneOff": "Turn off microphone",
"callMicrophoneOn": "Turn on microphone",
"callCameraOff": "Turn off camera",
"callCameraOn": "Turn on camera",
"callVideoFlip": "Mirror video",
"callSpeakerphoneToggle": "Toggle speakerphone",
"callScreenOff": "Turn off screen share",
"callScreenOn": "Turn on screen share"
}

View File

@ -202,5 +202,31 @@
"one": "搜索到 {} 个结果",
"other": "搜索到 {} 个结果"
},
"postSearchTook": "耗时 {}"
"postSearchTook": "耗时 {}",
"call": "通话",
"callOngoingNotice": "一则通话进行中",
"callJoin": "加入",
"callResume": "恢复",
"callMicrophone": "麦克风",
"callCamera": "摄像头",
"callMicrophoneDisabled": "麦克风已禁用",
"callMicrophoneSelect": "选择麦克风",
"callCameraDisabled": "摄像头已禁用",
"callCameraSelect": "选择摄像头",
"callDisconnected": "通话已断开",
"callEnded": "通话已结束",
"callStatusConnected": "已连接",
"callStatusDisconnected": "未连接",
"callStatusConnecting": "正在连接",
"callStatusReconnecting": "正在重连",
"callDisconnect": "断开连接",
"callDisconnectDescription": "您确定要与通话断开连接吗?",
"callMicrophoneOff": "关闭麦克风",
"callMicrophoneOn": "打开麦克风",
"callCameraOff": "关闭摄像头",
"callCameraOn": "打开摄像头",
"callVideoFlip": "镜像画面",
"callSpeakerphoneToggle": "切换扬声器",
"callScreenOff": "关闭屏幕共享",
"callScreenOn": "开启屏幕共享"
}

View File

@ -6,6 +6,8 @@ PODS:
- Flutter
- cupertino_http (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
@ -160,8 +162,9 @@ PODS:
- GoogleUtilities/Privacy
- image_picker_ios (0.0.1):
- Flutter
- isar_flutter_libs (1.0.0):
- livekit_client (2.3.0):
- Flutter
- WebRTC-SDK (= 125.6422.05)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
@ -180,6 +183,8 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
@ -211,6 +216,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
@ -220,13 +226,14 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@ -263,6 +270,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/croppy/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_analytics:
@ -281,8 +290,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_webrtc/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
@ -295,6 +304,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter:
@ -314,6 +325,7 @@ SPEC CHECKSUMS:
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@ -334,7 +346,7 @@ SPEC CHECKSUMS:
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
livekit_client: 5c31e13cd17dd0d545a074290c937dbdff1d809d
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -342,6 +354,7 @@ SPEC CHECKSUMS:
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625

View File

@ -74,18 +74,6 @@ class ChatMessageController extends ChangeNotifier {
final payload = SnChatMessage.fromJson(event.payload!);
_addMessage(payload);
break;
case 'calls.new':
final payload = SnChatMessage.fromJson(event.payload!);
if (payload.channel.id == channel?.id) {
// TODO impl call
}
break;
case 'calls.end':
final payload = SnChatMessage.fromJson(event.payload!);
if (payload.channel.id == channel?.id) {
// TODO impl call
}
break;
case 'status.typing':
if (event.payload?['channel_id'] != channel?.id) break;
final member = SnChannelMember.fromJson(event.payload!['member']);

View File

@ -12,6 +12,7 @@ import 'package:responsive_framework/responsive_framework.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_attachment.dart';
@ -86,6 +87,7 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
],
child: AppMainContent(),
),

View File

@ -0,0 +1,459 @@
import 'dart:async';
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:surface/providers/sn_network.dart';
import 'package:surface/types/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class ChatCallProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
ChatCallProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
SnChatCall? _current;
SnChannel? _channel;
bool _isReady = false;
bool _isMounted = false;
bool _isInitialized = false;
bool _isBusy = false;
String _lastDuration = '00:00:00';
Timer? _lastDurationUpdateTimer;
String? token;
String? endpoint;
StreamSubscription? hwSubscription;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
late Room _room;
late EventsListener<RoomEvent> _listener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusTrack;
// Getters for private fields
SnChatCall? get current => _current;
SnChannel? get channel => _channel;
bool get isReady => _isReady;
bool get isMounted => _isMounted;
bool get isInitialized => _isInitialized;
bool get isBusy => _isBusy;
String get lastDuration => _lastDuration;
List<MediaDevice> get audioInputs => _audioInputs;
List<MediaDevice> get videoInputs => _videoInputs;
bool get enableAudio => _enableAudio;
bool get enableVideo => _enableVideo;
LocalAudioTrack? get audioTrack => _audioTrack;
LocalVideoTrack? get videoTrack => _videoTrack;
MediaDevice? get videoDevice => _videoDevice;
MediaDevice? get audioDevice => _audioDevice;
List<ParticipantTrack> get participantTracks => _participantTracks;
ParticipantTrack? get focusTrack => _focusTrack;
Room get room => _room;
void _updateDuration() {
if (_current == null) {
_lastDuration = '00:00:00';
} else {
Duration duration = DateTime.now().difference(_current!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
_lastDuration = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
}
notifyListeners();
}
void enableDurationUpdater() {
_updateDuration();
_lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
_lastDurationUpdateTimer?.cancel();
_lastDurationUpdateTimer = null;
}
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();
}
void setCall(SnChatCall call, SnChannel related) {
_current = call;
_channel = related;
notifyListeners();
}
Future<(String, String)> getRoomToken() async {
final resp = await _sn.client.post(
'/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
);
token = resp.data['token'];
endpoint = 'wss://${resp.data['endpoint']}';
return (token!, endpoint!);
}
void initHardware() {
if (_isReady) return;
_isReady = true;
hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
_revertDevices,
);
Hardware.instance.enumerateDevices().then(_revertDevices);
notifyListeners();
}
void initRoom() {
initHardware();
_room = Room(
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParametersPresets.screenShareH1080FPS30,
),
defaultCameraCaptureOptions: CameraCaptureOptions(
maxFrameRate: 30,
params: VideoParametersPresets.h1080_169,
),
),
);
_listener = _room.createListener();
WakelockPlus.enable();
}
Future<void> joinRoom(String url, String token) async {
if (_isMounted) return;
try {
await _room.connect(
url,
token,
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: _audioTrack),
camera: TrackOption(track: _videoTrack),
),
);
} finally {
_isMounted = true;
notifyListeners();
}
}
void setupRoom() {
if (isInitialized) return;
sortParticipants();
_room.addListener(_onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback(
(_) => autoPublish(),
);
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
_isBusy = false;
_isInitialized = true;
notifyListeners();
}
void autoPublish() async {
try {
if (enableVideo) {
await _room.localParticipant?.setCameraEnabled(true);
}
if (enableAudio) {
await _room.localParticipant?.setMicrophoneEnabled(true);
}
} catch (error) {
rethrow;
}
}
Future<void> setEnableAudio(bool value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await _changeLocalAudioTrack();
}
notifyListeners();
}
Future<void> setEnableVideo(bool value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await _changeLocalVideoTrack();
}
notifyListeners();
}
void setupRoomListeners({
required Function(DisconnectReason?) onDisconnected,
}) {
_listener
..on<RoomDisconnectedEvent>((event) async {
onDisconnected(event.reason);
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
});
}
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 newTracks = List<ParticipantTrack>.empty(growable: true);
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;
});
newTracks.addAll(mediaTrackList);
if (_room.localParticipant != null) {
ParticipantTrack localTrack = ParticipantTrack(
participant: _room.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
final localParticipantTracks =
_room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
newTracks.add(localTrack);
}
_participantTracks = newTracks;
if (focusTrack != null) {
final idx = participantTracks
.indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
if (idx == -1) {
_focusTrack = null;
}
}
if (focusTrack == null) {
_focusTrack = participantTracks.firstOrNull;
} else {
final idx = participantTracks.indexWhere(
(x) => _focusTrack!.participant.sid == x.participant.sid,
);
if (idx > -1) {
_focusTrack = participantTracks[idx];
}
}
notifyListeners();
}
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();
}
notifyListeners();
}
Future<void> _changeLocalVideoTrack() async {
if (_videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (_videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(
CameraCaptureOptions(
deviceId: _videoDevice!.deviceId,
params: VideoParametersPresets.h1080_169,
),
);
await _videoTrack!.start();
}
notifyListeners();
}
void _revertDevices(List<MediaDevice> devices) {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
notifyListeners();
}
void _onRoomDidUpdate() => sortParticipants();
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: VideoParametersPresets.h1080_169,
),
);
await videoTrack!.start();
}
}
void deactivateHardware() {
hwSubscription?.cancel();
}
void disposeRoom() {
_isBusy = false;
_isMounted = false;
_isInitialized = false;
_current = null;
_channel = null;
_room.removeListener(_onRoomDidUpdate);
_room.disconnect();
_room.dispose();
_listener.dispose();
WakelockPlus.disable();
}
void disposeHardware() {
_isReady = false;
_audioTrack?.stop();
_audioTrack = null;
_videoTrack?.stop();
_videoTrack = null;
}
void setVideoDevice(MediaDevice? value) {
_videoDevice = value;
notifyListeners();
}
void setAudioDevice(MediaDevice? value) {
_audioDevice = value;
notifyListeners();
}
void setFocusTrack(ParticipantTrack? value) {
_focusTrack = value;
notifyListeners();
}
void setIsBusy(bool value) {
_isBusy = value;
notifyListeners();
}
}

View File

@ -114,7 +114,6 @@ final _appRoutes = [
path: '/:scope/:alias/call',
name: 'chatCallRoom',
builder: (context, state) => AppBackground(
isLessOptimization: true,
child: CallRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,

View File

@ -26,6 +26,7 @@ class AccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('settings');
},
),
const Gap(8),
],
),
body: SingleChildScrollView(

View File

@ -1,9 +1,14 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart' as livekit;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/chat/call/call_controls.dart';
import 'package:surface/widgets/chat/call/call_participant.dart';
class CallRoomScreen extends StatefulWidget {
final String scope;
@ -15,16 +20,301 @@ class CallRoomScreen extends StatefulWidget {
}
class _CallRoomScreenState extends State<CallRoomScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Voice Chat')),
body: Center(
child: ElevatedButton(
onPressed: () {},
child: Text('Start Call'),
int _layoutMode = 0;
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
Widget _buildListLayout() {
final call = context.read<ChatCallProvider>();
return Stack(
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: call.focusTrack != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack!,
onTap: () {},
)
: const SizedBox.shrink(),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
if (track.participant.sid == call.focusTrack?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
);
}
Widget _buildGridLayout() {
final call = context.read<ChatCallProvider>();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return StyledWidget(GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
)).padding(all: 8);
});
}
@override
void initState() {
super.initState();
final call = context.read<ChatCallProvider>();
Future.delayed(Duration.zero, () {
call
..setupRoom()
..enableDurationUpdater();
});
}
@override
Widget build(BuildContext context) {
final call = context.read<ChatCallProvider>();
return ListenableBuilder(
listenable: call,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr(),
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(color: Colors.white),
),
const TextSpan(text: '\n'),
TextSpan(
text: call.lastDuration.toString(),
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white),
),
]),
),
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
livekit.ConnectionState.disconnected:
'callStatusDisconnected'.tr(),
livekit.ConnectionState.connected:
'callStatusConnected'.tr(),
livekit.ConnectionState.connecting:
'callStatusConnecting'.tr(),
livekit.ConnectionState.reconnecting:
'callStatusReconnecting'.tr(),
}[call.room.connectionState]!,
),
const Gap(6),
if (connectionQuality !=
livekit.ConnectionQuality.unknown)
Icon(
{
livekit.ConnectionQuality.excellent:
Icons.signal_cellular_alt,
livekit.ConnectionQuality.good:
Icons.signal_cellular_alt_2_bar,
livekit.ConnectionQuality.poor:
Icons.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent:
Colors.green,
livekit.ConnectionQuality.good:
Colors.orange,
livekit.ConnectionQuality.poor:
Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
),
),
if (call.room.localParticipant != null)
SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
],
),
onTap: () {},
),
),
);
});
}
@override
void deactivate() {
final call = context.read<ChatCallProvider>();
call.disableDurationUpdater();
super.deactivate();
}
@override
void activate() {
final call = context.read<ChatCallProvider>();
call.enableDurationUpdater();
super.activate();
}
}

View File

@ -1,12 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_prejoin.dart';
import 'package:surface/widgets/chat/chat_message.dart';
import 'package:surface/widgets/chat/chat_message_input.dart';
import 'package:surface/widgets/dialog.dart';
@ -24,12 +34,16 @@ class ChatRoomScreen extends StatefulWidget {
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
SnChannel? _channel;
SnChatCall? _ongoingCall;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
StreamSubscription? _wsSubscription;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
@ -44,6 +58,87 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}
}
Future<void> _fetchOngoingCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
if (resp.statusCode == 200) {
_ongoingCall = SnChatCall.fromJson(resp.data);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _makeCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
options: Options(
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);
log(jsonDecode(resp.data));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _endCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.delete(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
);
log(jsonDecode(resp.data));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _onCallJoin() async {
await showModalBottomSheet(
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
onJoin: _onCallResume,
),
);
}
void _onCallResume() {
GoRouter.of(context).pushNamed(
'chatCallRoom',
pathParameters: {
'scope': _channel!.realm!.alias,
'alias': _channel!.alias,
},
);
}
@override
void initState() {
super.initState();
@ -51,30 +146,58 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_fetchChannel().then((_) async {
await _messageController.initialize(_channel!);
await _messageController.checkUpdate();
await _fetchOngoingCall();
});
final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.stream.stream.listen((event) {
switch (event.method) {
case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = payload);
}
break;
case 'calls.end':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = null);
}
break;
}
});
}
@override
void dispose() {
_wsSubscription?.cancel();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final call = context.watch<ChatCallProvider>();
return Scaffold(
appBar: AppBar(
title: Text(_channel?.name ?? 'loading'.tr()),
actions: [
IconButton(
onPressed: () {
GoRouter.of(context).pushNamed('chatCallRoom', pathParameters: {
'scope': widget.scope,
'alias': widget.alias,
});
},
icon: const Icon(Symbols.voice_chat),
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
IconButton(onPressed: () {}, icon: const Icon(Symbols.more_vert)),
IconButton(
icon: const Icon(Symbols.more_vert),
onPressed: () {},
),
const Gap(8),
],
),
body: ListenableBuilder(
@ -83,6 +206,28 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Symbols.call_received),
content: Text('callOngoingNotice').tr().padding(top: 2),
actions: [
if (call.current == null)
TextButton(
onPressed: _onCallJoin,
child: Text('callJoin').tr(),
)
else if (call.current?.channelId == _channel?.id)
TextButton(
onPressed: _onCallResume,
child: Text('callResume').tr(),
)
],
),
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.fastLinearToSlowEaseIn),
if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
@ -112,7 +257,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
idx > 0 ? _messageController.messages[idx - 1] : null;
final canMerge = nextMessage != null &&
nextMessage.updatedAt == nextMessage.createdAt &&
nextMessage.senderId == message.senderId &&
message.createdAt
.difference(nextMessage.createdAt)
@ -120,7 +264,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
.abs() <=
3;
final canMergePrevious = previousMessage != null &&
message.updatedAt == message.createdAt &&
previousMessage.senderId == message.senderId &&
message.createdAt
.difference(previousMessage.createdAt)

View File

@ -171,6 +171,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
GoRouter.of(context).pushNamed('postSearch');
},
),
const Gap(8),
],
),
SliverInfiniteList(

View File

@ -138,6 +138,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
const Gap(8),
],
),
body: Column(

View File

@ -146,6 +146,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta,
),
const Gap(8),
],
),
body: Column(

View File

@ -101,6 +101,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
icon: const Icon(Symbols.tune),
onPressed: _showAdvancedSearchTune,
),
const Gap(8),
],
),
body: Stack(

View File

@ -87,6 +87,7 @@ class _RealmScreenState extends State<RealmScreen> {
setState(() => _isCompactView = !_isCompactView);
},
),
const Gap(8),
],
),
floatingActionButton: FloatingActionButton(

View File

@ -39,6 +39,9 @@ Future<ThemeData> createAppTheme(
opticalSize: 20,
color: colorScheme.onSurface,
),
appBarTheme: AppBarTheme(
centerTitle: true,
),
scaffoldBackgroundColor: Colors.transparent,
);
}

View File

@ -1,5 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/realm.dart';
@ -101,3 +102,43 @@ class SnChatMessagePreload with _$SnChatMessagePreload {
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
_$SnChatMessagePreloadFromJson(json);
}
@freezed
class SnChatCall with _$SnChatCall {
const factory SnChatCall({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? endedAt,
required String externalId,
required int founderId,
required int channelId,
required SnChannelMember founder,
@Default([]) List<dynamic> participants,
}) = _SnChatCall;
factory SnChatCall.fromJson(Map<String, dynamic> json) =>
_$SnChatCallFromJson(json);
}
// Call stuff
enum ParticipantStatsType {
unknown,
localAudioSender,
localVideoSender,
remoteAudioReceiver,
remoteVideoReceiver,
}
class ParticipantTrack {
ParticipantTrack(
{required this.participant,
required this.videoTrack,
required this.isScreenShare});
VideoTrack? videoTrack;
Participant participant;
bool isScreenShare;
}

View File

@ -1788,3 +1788,376 @@ abstract class _SnChatMessagePreload extends SnChatMessagePreload {
_$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl>
get copyWith => throw _privateConstructorUsedError;
}
SnChatCall _$SnChatCallFromJson(Map<String, dynamic> json) {
return _SnChatCall.fromJson(json);
}
/// @nodoc
mixin _$SnChatCall {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
DateTime? get endedAt => throw _privateConstructorUsedError;
String get externalId => throw _privateConstructorUsedError;
int get founderId => throw _privateConstructorUsedError;
int get channelId => throw _privateConstructorUsedError;
SnChannelMember get founder => throw _privateConstructorUsedError;
List<dynamic> get participants => throw _privateConstructorUsedError;
/// Serializes this SnChatCall to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnChatCallCopyWith<SnChatCall> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnChatCallCopyWith<$Res> {
factory $SnChatCallCopyWith(
SnChatCall value, $Res Function(SnChatCall) then) =
_$SnChatCallCopyWithImpl<$Res, SnChatCall>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
DateTime? endedAt,
String externalId,
int founderId,
int channelId,
SnChannelMember founder,
List<dynamic> participants});
$SnChannelMemberCopyWith<$Res> get founder;
}
/// @nodoc
class _$SnChatCallCopyWithImpl<$Res, $Val extends SnChatCall>
implements $SnChatCallCopyWith<$Res> {
_$SnChatCallCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? endedAt = freezed,
Object? externalId = null,
Object? founderId = null,
Object? channelId = null,
Object? founder = null,
Object? participants = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
endedAt: freezed == endedAt
? _value.endedAt
: endedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
externalId: null == externalId
? _value.externalId
: externalId // ignore: cast_nullable_to_non_nullable
as String,
founderId: null == founderId
? _value.founderId
: founderId // ignore: cast_nullable_to_non_nullable
as int,
channelId: null == channelId
? _value.channelId
: channelId // ignore: cast_nullable_to_non_nullable
as int,
founder: null == founder
? _value.founder
: founder // ignore: cast_nullable_to_non_nullable
as SnChannelMember,
participants: null == participants
? _value.participants
: participants // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
) as $Val);
}
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnChannelMemberCopyWith<$Res> get founder {
return $SnChannelMemberCopyWith<$Res>(_value.founder, (value) {
return _then(_value.copyWith(founder: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnChatCallImplCopyWith<$Res>
implements $SnChatCallCopyWith<$Res> {
factory _$$SnChatCallImplCopyWith(
_$SnChatCallImpl value, $Res Function(_$SnChatCallImpl) then) =
__$$SnChatCallImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
DateTime? endedAt,
String externalId,
int founderId,
int channelId,
SnChannelMember founder,
List<dynamic> participants});
@override
$SnChannelMemberCopyWith<$Res> get founder;
}
/// @nodoc
class __$$SnChatCallImplCopyWithImpl<$Res>
extends _$SnChatCallCopyWithImpl<$Res, _$SnChatCallImpl>
implements _$$SnChatCallImplCopyWith<$Res> {
__$$SnChatCallImplCopyWithImpl(
_$SnChatCallImpl _value, $Res Function(_$SnChatCallImpl) _then)
: super(_value, _then);
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? endedAt = freezed,
Object? externalId = null,
Object? founderId = null,
Object? channelId = null,
Object? founder = null,
Object? participants = null,
}) {
return _then(_$SnChatCallImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
endedAt: freezed == endedAt
? _value.endedAt
: endedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
externalId: null == externalId
? _value.externalId
: externalId // ignore: cast_nullable_to_non_nullable
as String,
founderId: null == founderId
? _value.founderId
: founderId // ignore: cast_nullable_to_non_nullable
as int,
channelId: null == channelId
? _value.channelId
: channelId // ignore: cast_nullable_to_non_nullable
as int,
founder: null == founder
? _value.founder
: founder // ignore: cast_nullable_to_non_nullable
as SnChannelMember,
participants: null == participants
? _value._participants
: participants // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnChatCallImpl implements _SnChatCall {
const _$SnChatCallImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.endedAt,
required this.externalId,
required this.founderId,
required this.channelId,
required this.founder,
final List<dynamic> participants = const []})
: _participants = participants;
factory _$SnChatCallImpl.fromJson(Map<String, dynamic> json) =>
_$$SnChatCallImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final DateTime? endedAt;
@override
final String externalId;
@override
final int founderId;
@override
final int channelId;
@override
final SnChannelMember founder;
final List<dynamic> _participants;
@override
@JsonKey()
List<dynamic> get participants {
if (_participants is EqualUnmodifiableListView) return _participants;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_participants);
}
@override
String toString() {
return 'SnChatCall(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, endedAt: $endedAt, externalId: $externalId, founderId: $founderId, channelId: $channelId, founder: $founder, participants: $participants)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnChatCallImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.endedAt, endedAt) || other.endedAt == endedAt) &&
(identical(other.externalId, externalId) ||
other.externalId == externalId) &&
(identical(other.founderId, founderId) ||
other.founderId == founderId) &&
(identical(other.channelId, channelId) ||
other.channelId == channelId) &&
(identical(other.founder, founder) || other.founder == founder) &&
const DeepCollectionEquality()
.equals(other._participants, _participants));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
endedAt,
externalId,
founderId,
channelId,
founder,
const DeepCollectionEquality().hash(_participants));
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnChatCallImplCopyWith<_$SnChatCallImpl> get copyWith =>
__$$SnChatCallImplCopyWithImpl<_$SnChatCallImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnChatCallImplToJson(
this,
);
}
}
abstract class _SnChatCall implements SnChatCall {
const factory _SnChatCall(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final DateTime? endedAt,
required final String externalId,
required final int founderId,
required final int channelId,
required final SnChannelMember founder,
final List<dynamic> participants}) = _$SnChatCallImpl;
factory _SnChatCall.fromJson(Map<String, dynamic> json) =
_$SnChatCallImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
DateTime? get endedAt;
@override
String get externalId;
@override
int get founderId;
@override
int get channelId;
@override
SnChannelMember get founder;
@override
List<dynamic> get participants;
/// Create a copy of SnChatCall
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnChatCallImplCopyWith<_$SnChatCallImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -360,3 +360,36 @@ Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'quote_event': instance.quoteEvent?.toJson(),
};
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
_$SnChatCallImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
endedAt: json['ended_at'] == null
? null
: DateTime.parse(json['ended_at'] as String),
externalId: json['external_id'] as String,
founderId: (json['founder_id'] as num).toInt(),
channelId: (json['channel_id'] as num).toInt(),
founder:
SnChannelMember.fromJson(json['founder'] as Map<String, dynamic>),
participants: json['participants'] as List<dynamic>? ?? const [],
);
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'ended_at': instance.endedAt?.toIso8601String(),
'external_id': instance.externalId,
'founder_id': instance.founderId,
'channel_id': instance.channelId,
'founder': instance.founder.toJson(),
'participants': instance.participants,
};

View File

@ -290,7 +290,7 @@ class _AttachmentItemContentVideoState
),
),
const Icon(
Icons.play_arrow,
Symbols.play_arrow,
shadows: labelShadows,
color: Colors.white,
).padding(bottom: 4, right: 8),
@ -450,7 +450,7 @@ class _AttachmentItemContentAudioState
),
),
const Icon(
Icons.play_arrow,
Symbols.play_arrow,
shadows: labelShadows,
color: Colors.white,
).padding(bottom: 4, right: 8),

View File

@ -0,0 +1,369 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/dialog.dart';
class ControlsWidget extends StatefulWidget {
final Room room;
final LocalParticipant participant;
const ControlsWidget(
this.room,
this.participant, {
super.key,
});
@override
State<StatefulWidget> createState() => _ControlsWidgetState();
}
class _ControlsWidgetState extends State<ControlsWidget> {
CameraPosition _position = CameraPosition.front;
List<MediaDevice>? _audioInputs;
List<MediaDevice>? _audioOutputs;
List<MediaDevice>? _videoInputs;
StreamSubscription? _subscription;
bool _speakerphoneOn = false;
@override
void initState() {
super.initState();
_participant.addListener(onChange);
_subscription = Hardware.instance.onDeviceChange.stream
.listen((List<MediaDevice> devices) {
_revertDevices(devices);
});
Hardware.instance.enumerateDevices().then(_revertDevices);
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
}
@override
void dispose() {
_subscription?.cancel();
_participant.removeListener(onChange);
super.dispose();
}
LocalParticipant get _participant => widget.participant;
void _revertDevices(List<MediaDevice> devices) async {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
setState(() {});
}
void onChange() => setState(() {});
bool get isMuted => _participant.isMuted;
Future<bool?> showDisconnectDialog() {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('callDisconnect').tr(),
content: Text('callDisconnectDescription').tr(),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('dialogConfirm').tr(),
),
],
),
);
}
void _disconnect() async {
if (await showDisconnectDialog() != true) return;
if (!mounted) return;
final call = context.read<ChatCallProvider>();
if (call.current != null) {
call.disposeRoom();
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
}
void _disableAudio() async {
await _participant.setMicrophoneEnabled(false);
}
void _enableAudio() async {
await _participant.setMicrophoneEnabled(true);
}
void _disableVideo() async {
await _participant.setCameraEnabled(false);
}
void _enableVideo() async {
await _participant.setCameraEnabled(true);
}
void _selectAudioOutput(MediaDevice device) async {
await widget.room.setAudioOutputDevice(device);
setState(() {});
}
void _selectAudioInput(MediaDevice device) async {
await widget.room.setAudioInputDevice(device);
setState(() {});
}
void _selectVideoInput(MediaDevice device) async {
await widget.room.setVideoInputDevice(device);
setState(() {});
}
void _toggleSpeakerphoneOn() {
_speakerphoneOn = !_speakerphoneOn;
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
setState(() {});
}
void _toggleCamera() async {
final track = _participant.videoTrackPublications.firstOrNull?.track;
if (track == null) return;
try {
final newPosition = _position.switched();
await track.setCameraPosition(newPosition);
setState(() {
_position = newPosition;
});
} catch (error) {
return;
}
}
void _enableScreenShare() async {
if (lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
captureScreenAudio: true,
sourceId: source.id,
maxFrameRate: 30.0,
),
);
await _participant.publishVideoTrack(track);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
return;
}
if (lkPlatformIs(PlatformType.iOS)) {
var track = await LocalVideoTrack.createScreenShareTrack(
const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
captureScreenAudio: true,
maxFrameRate: 30.0,
),
);
await _participant.publishVideoTrack(track);
return;
}
if (lkPlatformIsWebMobile()) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Screen share is not supported mobile platform.'),
));
return;
}
await _participant.setScreenShareEnabled(true, captureScreenAudio: true);
}
void _disableScreenShare() async {
await _participant.setScreenShareEnabled(false);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 10,
),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 5,
runSpacing: 5,
children: [
IconButton(
icon: const Icon(Symbols.exit_to_app),
color: Theme.of(context).colorScheme.onSurface,
onPressed: _disconnect,
),
if (_participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android))
IconButton(
onPressed: _disableAudio,
icon: const Icon(Symbols.mic),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOff'.tr(),
)
else
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.settings_voice),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: isMuted ? _enableAudio : _disableAudio,
child: ListTile(
leading: const Icon(Symbols.mic_off),
title: Text(isMuted
? 'callMicrophoneOn'.tr()
: 'callMicrophoneOff'.tr()),
),
),
if (_audioInputs != null)
..._audioInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedAudioInputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectAudioInput(device),
);
})
];
},
)
else
IconButton(
onPressed: _enableAudio,
icon: const Icon(Symbols.mic_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOn'.tr(),
),
if (_participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: _disableVideo,
child: ListTile(
leading: const Icon(Symbols.videocam_off),
title: Text('callCameraOff'.tr()),
),
),
if (_videoInputs != null)
..._videoInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedVideoInputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectVideoInput(device),
);
})
];
},
)
else
IconButton(
onPressed: _enableVideo,
icon: const Icon(Symbols.videocam_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callCameraOn'.tr(),
),
IconButton(
icon: Icon(_position == CameraPosition.back
? Symbols.video_camera_back
: Symbols.video_camera_front),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _toggleCamera(),
tooltip: 'callVideoFlip'.tr(),
),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.volume_up),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
child: ListTile(
leading: const Icon(Symbols.speaker),
title: Text('callSpeakerSelect').tr(),
),
),
if (_audioOutputs != null)
..._audioOutputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedAudioOutputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectAudioOutput(device),
);
})
];
},
),
if (!kIsWeb && Hardware.instance.canSwitchSpeakerphone)
IconButton(
onPressed: _toggleSpeakerphoneOn,
color: Theme.of(context).colorScheme.onSurface,
icon: _speakerphoneOn
? Icon(Symbols.volume_up)
: Icon(Symbols.volume_down),
tooltip: 'callSpeakerphoneToggle'.tr(),
),
if (_participant.isScreenShareEnabled())
IconButton(
icon: const Icon(Symbols.stop_screen_share),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _disableScreenShare(),
tooltip: 'callScreenOff'.tr(),
)
else
IconButton(
icon: const Icon(Symbols.screen_share),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _enableScreenShare(),
tooltip: 'callScreenOn'.tr(),
),
],
),
);
}
}

View File

@ -0,0 +1,92 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
class NoContentWidget extends StatefulWidget {
final SnAccount? userinfo;
final bool isSpeaking;
final bool isFixed;
const NoContentWidget({
super.key,
this.userinfo,
this.isFixed = false,
required this.isSpeaking,
});
@override
State<NoContentWidget> createState() => _NoContentWidgetState();
}
class _NoContentWidgetState extends State<NoContentWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(vsync: this);
}
@override
void didUpdateWidget(NoContentWidget old) {
super.didUpdateWidget(old);
if (widget.isSpeaking) {
_animationController.repeat(reverse: true);
} else {
_animationController
.animateTo(0, duration: 300.ms)
.then((_) => _animationController.reset());
}
}
@override
Widget build(BuildContext context) {
final double radius = widget.isFixed
? 32
: math.min(
MediaQuery.of(context).size.width * 0.1,
MediaQuery.of(context).size.height * 0.1,
);
return Container(
alignment: Alignment.center,
child: Center(
child: Animate(
autoPlay: false,
controller: _animationController,
effects: [
CustomEffect(
begin: widget.isSpeaking ? 2 : 0,
end: 8,
curve: Curves.easeInOut,
duration: 1250.ms,
builder: (context, value, child) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
border: value > 0
? Border.all(color: Colors.green, width: value)
: null,
),
child: child,
),
)
],
child: AccountImage(
content: widget.userinfo?.avatar,
radius: radius,
),
),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,242 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_no_content.dart';
import 'package:surface/widgets/chat/call/call_participant_info.dart';
import 'package:surface/widgets/chat/call/call_participant_menu.dart';
import 'package:surface/widgets/chat/call/call_participant_stats.dart';
abstract class ParticipantWidget extends StatefulWidget {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
{bool isFixed = false, bool showStatsLayer = false}) {
if (participantTrack.participant is LocalParticipant) {
return LocalParticipantWidget(
participantTrack.participant as LocalParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
} else if (participantTrack.participant is RemoteParticipant) {
return RemoteParticipantWidget(
participantTrack.participant as RemoteParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
}
throw UnimplementedError('Unknown participant type');
}
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool isFixed;
abstract final bool showStatsLayer;
final VideoQuality quality;
const ParticipantWidget({
super.key,
this.quality = VideoQuality.MEDIUM,
});
}
class LocalParticipantWidget extends ParticipantWidget {
@override
final LocalParticipant participant;
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
});
@override
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
}
class RemoteParticipantWidget extends ParticipantWidget {
@override
final RemoteParticipant participant;
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
const RemoteParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
});
@override
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
}
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
extends State<T> {
VideoTrack? get _activeVideoTrack;
TrackPublication? get _firstAudioPublication;
SnAccount? _userinfoMetadata;
@override
void initState() {
super.initState();
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
}
@override
void dispose() {
widget.participant.removeListener(onParticipantChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant T oldWidget) {
oldWidget.participant.removeListener(onParticipantChanged);
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
super.didUpdateWidget(oldWidget);
}
void onParticipantChanged() {
setState(() {
if (widget.participant.metadata != null) {
_userinfoMetadata = SnAccount.fromJson(
jsonDecode(widget.participant.metadata!),
);
}
});
}
@override
Widget build(BuildContext ctx) {
return Stack(
children: [
_activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isFixed: widget.isFixed,
isSpeaking: widget.participant.isSpeaking,
),
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(participant: widget.participant),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? widget.participant.name
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false &&
_firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
),
],
),
),
],
);
}
}
class _LocalParticipantWidgetState
extends _ParticipantWidgetState<LocalParticipantWidget> {
@override
LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class _RemoteParticipantWidgetState
extends _ParticipantWidgetState<RemoteParticipantWidget> {
@override
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final bool isFixedAvatar;
final ParticipantTrack participant;
final Function() onTap;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
this.isFixedAvatar = false,
required this.participant,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class ParticipantInfoWidget extends StatelessWidget {
final String? title;
final bool audioAvailable;
final ConnectionQuality connectionQuality;
final bool isScreenShare;
const ParticipantInfoWidget({
super.key,
this.title,
this.audioAvailable = true,
this.connectionQuality = ConnectionQuality.unknown,
this.isScreenShare = false,
});
@override
Widget build(BuildContext context) => Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const Gap(5),
isScreenShare
? const Icon(
Symbols.monitor,
color: Colors.white,
size: 16,
)
: Icon(
audioAvailable ? Symbols.mic : Symbols.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
const Gap(3),
if (connectionQuality != ConnectionQuality.unknown)
Icon(
{
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
);
}

View File

@ -0,0 +1,161 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
class ParticipantMenu extends StatefulWidget {
final RemoteParticipant participant;
final VideoTrack? videoTrack;
final bool isScreenShare;
final bool showStatsLayer;
const ParticipantMenu({
super.key,
required this.participant,
this.videoTrack,
this.isScreenShare = false,
this.showStatsLayer = false,
});
@override
State<ParticipantMenu> createState() => _ParticipantMenuState();
}
class _ParticipantMenuState extends State<ParticipantMenu> {
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications
.where((element) => element.sid == widget.videoTrack?.sid)
.firstOrNull;
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
void tookAction() {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
child: Text(
'callParticipantAction',
style: Theme.of(context).textTheme.headlineSmall,
).tr(),
),
),
Expanded(
child: ListView(
children: [
if (_firstAudioPublication != null && !widget.isScreenShare)
ListTile(
leading: Icon(
Symbols.volume_up,
color: {
TrackSubscriptionState.notAllowed:
Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
TrackSubscriptionState.subscribed:
Theme.of(context).colorScheme.primary,
}[_firstAudioPublication!.subscriptionState],
),
title: Text(
_firstAudioPublication!.subscribed
? 'callParticipantMicrophoneOff'.tr()
: 'callParticipantMicrophoneOn'.tr(),
),
onTap: () {
if (_firstAudioPublication!.subscribed) {
_firstAudioPublication!.unsubscribe();
} else {
_firstAudioPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null)
ListTile(
leading: Icon(
widget.isScreenShare ? Symbols.monitor : Symbols.videocam,
color: {
TrackSubscriptionState.notAllowed:
Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
TrackSubscriptionState.subscribed:
Theme.of(context).colorScheme.primary,
}[_videoPublication!.subscriptionState],
),
title: Text(
_videoPublication!.subscribed
? 'callParticipantVideoOff'.tr()
: 'callParticipantVideoOn'.tr(),
),
onTap: () {
if (_videoPublication!.subscribed) {
_videoPublication!.unsubscribe();
} else {
_videoPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[30, 15, 8].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.fps == x
? Symbols.check_box
: Symbols.check_box_outline_blank,
),
title: Text('Set preferred frame-per-second to $x'),
onTap: () {
_videoPublication!.setVideoFPS(x);
tookAction();
},
),
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[
('High', VideoQuality.HIGH),
('Medium', VideoQuality.MEDIUM),
('Low', VideoQuality.LOW),
].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.videoQuality == x.$2
? Symbols.check_box
: Symbols.check_box_outline_blank,
),
title: Text('Set preferred quality to ${x.$1}'),
onTap: () {
_videoPublication!.setVideoQuality(x.$2);
tookAction();
},
),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/chat.dart';
class ParticipantStatsWidget extends StatefulWidget {
const ParticipantStatsWidget({super.key, required this.participant});
final Participant participant;
@override
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
}
class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
List<EventsListener<TrackEvent>> listeners = [];
ParticipantStatsType statsType = ParticipantStatsType.unknown;
Map<String, String> stats = {};
void _setUpListener(Track track) {
var listener = track.createListener();
listeners.add(listener);
if (track is LocalVideoTrack) {
statsType = ParticipantStatsType.localVideoSender;
listener.on<VideoSenderStatsEvent>((event) {
setState(() {
stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
event.stats.forEach((key, value) {
stats['layer-$key'] =
'${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
});
var firstStats =
event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
if (firstStats != null) {
stats['encoder'] = firstStats.encoderImplementation ?? '';
stats['video codec'] =
'${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
stats['qualityLimitationReason'] =
firstStats.qualityLimitationReason ?? '';
}
});
});
} else if (track is RemoteVideoTrack) {
statsType = ParticipantStatsType.remoteVideoReceiver;
listener.on<VideoReceiverStatsEvent>((event) {
setState(() {
stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['video codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
stats['video size'] =
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
stats['video jitter'] = '${event.stats.jitter} s';
stats['video decoder'] = '${event.stats.decoderImplementation}';
stats['video packets lost'] = '${event.stats.packetsLost}';
stats['video packets received'] = '${event.stats.packetsReceived}';
stats['video frames received'] = '${event.stats.framesReceived}';
stats['video frames decoded'] = '${event.stats.framesDecoded}';
stats['video frames dropped'] = '${event.stats.framesDropped}';
});
});
} else if (track is LocalAudioTrack) {
statsType = ParticipantStatsType.localAudioSender;
listener.on<AudioSenderStatsEvent>((event) {
setState(() {
stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
});
});
} else if (track is RemoteAudioTrack) {
statsType = ParticipantStatsType.remoteAudioReceiver;
listener.on<AudioReceiverStatsEvent>((event) {
setState(() {
stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
stats['audio jitter'] = '${event.stats.jitter} s';
stats['audio concealed samples'] =
'${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
stats['audio packets lost'] = '${event.stats.packetsLost}';
stats['audio packets received'] = '${event.stats.packetsReceived}';
});
});
}
}
onParticipantChanged() {
for (var element in listeners) {
element.dispose();
}
listeners.clear();
for (var track in [
...widget.participant.videoTrackPublications,
...widget.participant.audioTrackPublications
]) {
if (track.track != null) {
_setUpListener(track.track!);
}
}
}
@override
void initState() {
super.initState();
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
}
@override
void deactivate() {
for (var element in listeners) {
element.dispose();
}
widget.participant.removeListener(onParticipantChanged);
super.deactivate();
}
num sendBitrate = 0;
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
child: Column(
children:
stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart';
class ChatCallPrejoinPopup extends StatefulWidget {
final SnChatCall ongoingCall;
final SnChannel channel;
final void Function() onJoin;
const ChatCallPrejoinPopup({
super.key,
required this.ongoingCall,
required this.channel,
required this.onJoin,
});
@override
State<ChatCallPrejoinPopup> createState() => _ChatCallPrejoinPopupState();
}
class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
bool _isBusy = false;
late final ChatCallProvider _call = context.read<ChatCallProvider>();
void _performJoin() async {
setState(() => _isBusy = true);
_call.setCall(widget.ongoingCall, widget.channel);
_call.setIsBusy(true);
try {
final resp = await _call.getRoomToken();
final token = resp.$1;
final endpoint = resp.$2;
_call.initRoom();
_call.setupRoomListeners(
onDisconnected: (reason) {
context.showSnackbar(
'callDisconnected'.tr(args: [reason.toString()]),
);
},
);
await _call.joinRoom(endpoint, token);
widget.onJoin();
if (!mounted) return;
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
final call = context.read<ChatCallProvider>();
call.checkPermissions().then((_) {
call.initHardware();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final call = context.read<ChatCallProvider>();
return ListenableBuilder(
listenable: call,
builder: (context, _) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('callMicrophone').tr(),
Switch(
value: call.enableAudio,
onChanged: null,
),
],
).padding(bottom: 5),
DropdownButtonHideUnderline(
child: DropdownButton2<MediaDevice>(
isExpanded: true,
disabledHint: Text('callMicrophoneDisabled').tr(),
hint: Text('callMicrophoneSelect').tr(),
items: call.enableAudio
? call.audioInputs
.map(
(item) => DropdownMenuItem<MediaDevice>(
value: item,
child: Text(item.label),
),
)
.toList()
.cast<DropdownMenuItem<MediaDevice>>()
: [],
value: call.audioDevice,
onChanged: (MediaDevice? value) async {
if (value != null) {
call.setAudioDevice(value);
await call.changeLocalAudioTrack();
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
width: 320,
),
),
).padding(bottom: 25),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('callCamera').tr(),
Switch(
value: call.enableVideo,
onChanged: (value) => call.setEnableAudio(value),
),
],
).padding(bottom: 5),
DropdownButtonHideUnderline(
child: DropdownButton2<MediaDevice>(
isExpanded: true,
disabledHint: Text('callCameraDisabled').tr(),
hint: Text('callCameraSelect').tr(),
items: call.enableVideo
? call.videoInputs
.map(
(item) => DropdownMenuItem<MediaDevice>(
value: item,
child: Text(item.label),
),
)
.toList()
.cast<DropdownMenuItem<MediaDevice>>()
: [],
value: call.videoDevice,
onChanged: (MediaDevice? value) async {
if (value != null) {
call.setVideoDevice(value);
await call.changeLocalVideoTrack();
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
width: 320,
),
),
).padding(bottom: 25),
if (_isBusy)
const Center(child: CircularProgressIndicator())
else
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(320, 56),
),
onPressed: _isBusy ? null : _performJoin,
child: Text('callJoin').tr(),
),
],
),
),
);
},
);
}
@override
void dispose() {
_call
..deactivateHardware()
..disposeHardware();
super.dispose();
}
}

View File

@ -1226,6 +1226,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
url: "https://pub.dev"
source: hosted
version: "11.3.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
url: "https://pub.dev"
source: hosted
version: "12.0.13"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
url: "https://pub.dev"
source: hosted
version: "9.4.5"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9
url: "https://pub.dev"
source: hosted
version: "4.2.3"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@ -1792,7 +1840,7 @@ packages:
source: hosted
version: "2.0.8"
wakelock_plus:
dependency: transitive
dependency: "direct main"
description:
name: wakelock_plus
sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484

View File

@ -89,6 +89,8 @@ dependencies:
sentry_flutter: ^8.10.1
synchronized: ^3.3.0+3
livekit_client: ^2.3.0
wakelock_plus: ^1.2.8
permission_handler: ^11.3.1
dev_dependencies:
flutter_test:

View File

@ -15,6 +15,7 @@
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <pasteboard/pasteboard_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <screen_brightness_windows/screen_brightness_windows_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -38,6 +39,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
ScreenBrightnessWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin"));
SentryFlutterPluginRegisterWithRegistrar(

View File

@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_windows_video
media_kit_video
pasteboard
permission_handler_windows
screen_brightness_windows
sentry_flutter
url_launcher_windows