➕ Add webrtc deps
This commit is contained in:
@ -4,6 +4,7 @@ import 'package:easy_localization_loader/easy_localization_loader.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
@ -22,6 +23,7 @@ import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/router.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -41,6 +43,9 @@ void main() async {
|
||||
debugInvertOversizedImages = true;
|
||||
}
|
||||
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
usePathUrlStrategy();
|
||||
|
||||
await SentryFlutter.init(
|
||||
(options) {
|
||||
options.dsn =
|
||||
|
@ -10,6 +10,7 @@ import 'package:surface/screens/album.dart';
|
||||
import 'package:surface/screens/auth/login.dart';
|
||||
import 'package:surface/screens/auth/register.dart';
|
||||
import 'package:surface/screens/chat.dart';
|
||||
import 'package:surface/screens/chat/call_room.dart';
|
||||
import 'package:surface/screens/chat/manage.dart';
|
||||
import 'package:surface/screens/chat/room.dart';
|
||||
import 'package:surface/screens/explore.dart';
|
||||
@ -47,7 +48,7 @@ final _appRoutes = [
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/post/write/:mode',
|
||||
path: '/write/:mode',
|
||||
name: 'postEditor',
|
||||
builder: (context, state) => AppBackground(
|
||||
isLessOptimization: true,
|
||||
@ -66,14 +67,14 @@ final _appRoutes = [
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/search',
|
||||
path: '/search',
|
||||
name: 'postSearch',
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: PostSearchScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:slug',
|
||||
path: '/:slug',
|
||||
name: 'postDetail',
|
||||
builder: (context, state) => AppBackground(
|
||||
child: PostDetailScreen(
|
||||
@ -99,7 +100,7 @@ final _appRoutes = [
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/chat/:scope/:alias',
|
||||
path: '/:scope/:alias',
|
||||
name: 'chatRoom',
|
||||
builder: (context, state) => AppBackground(
|
||||
isLessOptimization: true,
|
||||
@ -110,7 +111,18 @@ final _appRoutes = [
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat/manage',
|
||||
path: '/:scope/:alias/call',
|
||||
name: 'chatCallRoom',
|
||||
builder: (context, state) => AppBackground(
|
||||
isLessOptimization: true,
|
||||
child: CallRoomScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/manage',
|
||||
name: 'chatManage',
|
||||
pageBuilder: (context, state) => CustomTransitionPage(
|
||||
child: ChatManageScreen(),
|
||||
@ -138,7 +150,7 @@ final _appRoutes = [
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/realm/manage',
|
||||
path: '/manage',
|
||||
name: 'realmManage',
|
||||
pageBuilder: (context, state) => CustomTransitionPage(
|
||||
child: RealmManageScreen(
|
||||
|
137
lib/screens/chat/call_room.dart
Normal file
137
lib/screens/chat/call_room.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
class CallRoomScreen extends StatefulWidget {
|
||||
final String scope;
|
||||
final String alias;
|
||||
const CallRoomScreen({super.key, required this.scope, required this.alias});
|
||||
|
||||
@override
|
||||
State<CallRoomScreen> createState() => _CallRoomScreenState();
|
||||
}
|
||||
|
||||
const _kLocalWebRtcBaseUrl = 'http://localhost:8001';
|
||||
|
||||
class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
MediaStream? _localStream;
|
||||
WebSocketChannel? _wsChannel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initWebRtc();
|
||||
}
|
||||
|
||||
Future<void> _initWebRtc() async {
|
||||
final client = Dio();
|
||||
client.options.baseUrl = _kLocalWebRtcBaseUrl;
|
||||
|
||||
final configResp = await client.get('/.well-known/webrtc');
|
||||
|
||||
// Get user media (audio only)
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': false,
|
||||
});
|
||||
|
||||
// Configure Peer Connection
|
||||
Map<String, dynamic> config = {
|
||||
'iceServers': configResp.data['ice_servers']
|
||||
};
|
||||
|
||||
_peerConnection = await createPeerConnection(config);
|
||||
|
||||
// Add local stream to peer connection
|
||||
_peerConnection?.addStream(_localStream!);
|
||||
|
||||
// Listen for ICE candidates
|
||||
_peerConnection?.onIceCandidate = (RTCIceCandidate candidate) {
|
||||
print('New ICE candidate: ${candidate.candidate}');
|
||||
// Send the candidate to the signaling server
|
||||
};
|
||||
|
||||
// Handle remote stream
|
||||
_peerConnection?.onAddStream = (MediaStream stream) {
|
||||
print('Remote stream added');
|
||||
// Play the remote stream
|
||||
};
|
||||
|
||||
_wsChannel = WebSocketChannel.connect(
|
||||
Uri.parse('$_kLocalWebRtcBaseUrl/webrtc'),
|
||||
);
|
||||
await _wsChannel!.ready;
|
||||
|
||||
_wsChannel!.stream.listen((event) {
|
||||
final Map<String, dynamic> data = jsonDecode(event);
|
||||
|
||||
switch (data['type']) {
|
||||
case 'offer':
|
||||
_handleOffer(data);
|
||||
break;
|
||||
case 'answer':
|
||||
_handleAnswer(data);
|
||||
break;
|
||||
case 'candidate':
|
||||
_handleCandidate(data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleOffer(Map<String, dynamic> data) async {
|
||||
// Set remote description
|
||||
final offer = RTCSessionDescription(data['sdp'], data['type']);
|
||||
await _peerConnection?.setRemoteDescription(offer);
|
||||
|
||||
// Create and send answer
|
||||
final answer = await _peerConnection?.createAnswer();
|
||||
await _peerConnection?.setLocalDescription(answer!);
|
||||
|
||||
_wsChannel?.sink.add({
|
||||
'type': 'answer',
|
||||
'sdp': answer?.sdp,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleAnswer(Map<String, dynamic> data) async {
|
||||
// Set remote description
|
||||
final answer = RTCSessionDescription(data['sdp'], data['type']);
|
||||
await _peerConnection?.setRemoteDescription(answer);
|
||||
}
|
||||
|
||||
Future<void> _handleCandidate(Map<String, dynamic> data) async {
|
||||
// Add ICE candidate
|
||||
final candidate = RTCIceCandidate(
|
||||
data['candidate'],
|
||||
data['sdpMid'],
|
||||
data['sdpMLineIndex'],
|
||||
);
|
||||
await _peerConnection?.addCandidate(candidate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_wsChannel?.sink.close();
|
||||
_localStream?.dispose();
|
||||
_peerConnection?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Voice Chat')),
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: Text('Start Call'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.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';
|
||||
@ -62,6 +64,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
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),
|
||||
),
|
||||
IconButton(onPressed: () {}, icon: const Icon(Symbols.more_vert)),
|
||||
],
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _messageController,
|
||||
|
@ -122,21 +122,25 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.comment, size: 24),
|
||||
const Gap(16),
|
||||
Text('postCommentsDetailed')
|
||||
.plural(_data!.metric.replyCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.comment, size: 24),
|
||||
const Gap(16),
|
||||
Text('postCommentsDetailed')
|
||||
.plural(_data!.metric.replyCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12).center(),
|
||||
),
|
||||
),
|
||||
if (_data != null && ua.isAuthorized)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
height: 240,
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.symmetric(
|
||||
horizontal: BorderSide(
|
||||
@ -158,7 +162,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
_childListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
if (_data != null)
|
||||
PostCommentSliverList(
|
||||
|
Reference in New Issue
Block a user