先把 Flutter 端 1V1 音视频跑通,信令服务器使用(register/offer/answer/candidate/hangup 的 WebSocket 转发协议)
一、增强版 server.js(推荐)
bash
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 3000 });
// id -> ws
const clients = new Map();
// ws -> meta
function now() { return Date.now(); }
function send(ws, obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
function broadcast(obj) {
for (const ws of clients.values()) send(ws, obj);
}
wss.on("connection", (ws, req) => {
ws.isAlive = true;
ws.lastSeen = now();
ws.on("pong", () => {
ws.isAlive = true;
ws.lastSeen = now();
});
ws.on("message", (message) => {
let data;
try {
data = JSON.parse(message);
} catch (e) {
return send(ws, { type: "error", code: "BAD_JSON", message: "Invalid JSON" });
}
ws.lastSeen = now();
// 1) register
if (data.type === "register") {
if (!data.id) return send(ws, { type: "error", code: "NO_ID", message: "Missing id" });
// 如果同一 id 重连,踢掉旧连接
const old = clients.get(data.id);
if (old && old !== ws) {
try { old.close(4000, "replaced by new connection"); } catch (_) {}
}
ws.id = data.id;
clients.set(data.id, ws);
console.log("Registered:", data.id, "ip:", req.socket.remoteAddress);
return send(ws, { type: "registered", id: data.id });
}
// 2) 其他消息:必须先注册
if (!ws.id) {
return send(ws, { type: "error", code: "NOT_REGISTERED", message: "Please register first" });
}
// 3) 转发逻辑:必须有 to
if (!data.to) {
return send(ws, { type: "error", code: "NO_TO", message: "Missing 'to'" });
}
const peer = clients.get(data.to);
if (!peer) {
return send(ws, { type: "error", code: "PEER_OFFLINE", message: "Peer is offline", to: data.to });
}
// 补充 from,避免客户端忘记带
data.from = data.from || ws.id;
send(peer, data);
});
ws.on("close", (code, reason) => {
if (ws.id && clients.get(ws.id) === ws) {
clients.delete(ws.id);
// 最终修正:放弃可选链,改用更兼容、更清晰的判断方式
const reasonStr = reason ? reason.toString() : "";
console.log("Disconnected:", ws.id, code, reasonStr);
}
});
ws.on("error", (err) => {
console.log("WS error:", err.message);
});
});
// 心跳:清理死连接
const interval = setInterval(() => {
for (const [id, ws] of clients.entries()) {
if (ws.isAlive === false) {
clients.delete(id);
try { ws.terminate(); } catch (_) {}
console.log("Terminated dead client:", id);
continue;
}
ws.isAlive = false;
try { ws.ping(); } catch (_) {}
}
}, 15000);
wss.on("close", () => clearInterval(interval));
console.log("WebSocket signaling server running on port 3000");
重新运行
二、Flutter 端
- pubspec.yaml(关键依赖)
bash
name: webrtc
description: A new Flutter project for 1v1 WebRTC call.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
web_socket_channel: ^2.4.0 # WebSocket通信
permission_handler: ^10.4.0 # 权限申请(摄像头/麦克风)
flutter_webrtc: ^0.9.34 # 兼容flutter的WebRTC封装
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true
- lib/main.dart(入口,只路由到 webrtc.dart)
bash
import 'package:flutter/material.dart';
import 'webrtc.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'RTC 1v1',
theme: ThemeData(useMaterial3: true),
home: const WebRtcPage(),
);
}
}
- lib/webrtc.dart(核心:UI + 信令 + WebRTC)
bash
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebRtcPage extends StatefulWidget {
const WebRtcPage({super.key});
@override
State<WebRtcPage> createState() => _WebRtcPageState();
}
class _WebRtcPageState extends State<WebRtcPage> {
// ====== 改这里:你的信令 ws 地址(wss 推荐)======
// 例:wss://xx.amoa.cn/ws
final String signalingUrl = 'wss://xx.amoa.cn/ws';
// ====== 改这里:你的 TURN(已验证可用:443/tcp)======
final List<Map<String, dynamic>> iceServers = [
{
'urls': ['turn:123.129.219.235:443?transport=tcp'],
'username': 'lanz', // 先沿用固定,后面你会换成临时凭证
'credential': 'QAZ123',
}
];
// UI
final _myIdCtrl = TextEditingController(text: 'A');
final _peerIdCtrl = TextEditingController(text: 'B');
// WebRTC
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
RTCPeerConnection? _pc;
MediaStream? _localStream;
// Signaling
WebSocketChannel? _ws;
StreamSubscription? _wsSub;
// 状态
String _status = 'idle';
bool _connected = false;
bool _inCall = false;
bool _incoming = false;
Map<String, dynamic>? _pendingOffer;
final List<RTCIceCandidate> _pendingCandidates = [];
String get myId => _myIdCtrl.text.trim();
String get peerId => _peerIdCtrl.text.trim();
@override
void initState() {
super.initState();
_initRenderers();
}
Future<void> _initRenderers() async {
await _localRenderer.initialize();
await _remoteRenderer.initialize();
setState(() {});
}
@override
void dispose() {
_wsSub?.cancel();
_ws?.sink.close();
_hangup(localOnly: true);
_localRenderer.dispose();
_remoteRenderer.dispose();
_myIdCtrl.dispose();
_peerIdCtrl.dispose();
super.dispose();
}
// ---------------- Signaling ----------------
Future<void> connect() async {
if (myId.isEmpty) return _toast('请输入我的ID');
try {
_ws = WebSocketChannel.connect(Uri.parse(signalingUrl));
_wsSub = _ws!.stream.listen((event) async {
final data = jsonDecode(event as String) as Map<String, dynamic>;
await _onSignal(data);
}, onError: (e) {
setState(() {
_connected = false;
_status = 'ws error: $e';
});
}, onDone: () {
setState(() {
_connected = false;
_status = 'ws closed';
});
});
// register
_send({
'type': 'register',
'id': myId,
});
setState(() {
_connected = true;
_status = 'registered: $myId';
});
} catch (e) {
setState(() => _status = 'connect failed: $e');
}
}
void _send(Map<String, dynamic> msg) {
final s = jsonEncode(msg);
_ws?.sink.add(s);
}
Future<void> _onSignal(Map<String, dynamic> msg) async {
final type = msg['type'];
if (type == 'offer') {
// 收到来电 offer:先保存,等待用户点击"接听"
_pendingOffer = msg;
setState(() {
_incoming = true;
_status = 'incoming offer from ${msg['from']}';
});
return;
}
if (type == 'answer') {
final ans = msg['answer'];
if (_pc == null) return;
await _pc!.setRemoteDescription(
RTCSessionDescription(ans['sdp'], ans['type']),
);
await _flushPendingCandidates();
setState(() => _status = 'answer setRemoteDescription done');
return;
}
if (type == 'candidate') {
final c = msg['candidate'];
final cand = RTCIceCandidate(c['candidate'], c['sdpMid'], c['sdpMLineIndex']);
if (_pc != null && (await _pc!.getRemoteDescription()) != null) {
await _pc!.addCandidate(cand);
} else {
_pendingCandidates.add(cand); // 缓存,等 remoteDescription
}
return;
}
if (type == 'hangup') {
await _hangup(localOnly: true);
setState(() {
_incoming = false;
_inCall = false;
_status = 'peer hung up';
});
return;
}
}
// ---------------- WebRTC core ----------------
Future<void> _ensurePeer() async {
if (_pc != null) return;
final config = {
'iceServers': iceServers,
// 你现在测试阶段可以强制 relay;上线可改成 "all"(默认)做 P2P+TURN
'iceTransportPolicy': 'relay',
};
_pc = await createPeerConnection(config);
_pc!.onIceCandidate = (c) {
if (c == null) return;
_send({
'type': 'candidate',
'from': myId,
'to': peerId,
'candidate': {
'candidate': c.candidate,
'sdpMid': c.sdpMid,
'sdpMLineIndex': c.sdpMLineIndex,
}
});
};
_pc!.onTrack = (event) {
if (event.streams.isNotEmpty) {
_remoteRenderer.srcObject = event.streams[0];
setState(() {});
}
};
_pc!.onIceConnectionState = (s) {
setState(() => _status = 'ice: $s');
};
_pc!.onConnectionState = (s) {
setState(() => _status = 'pc: $s');
};
// 本地流只创建一次,避免摄像头抢占
if (_localStream == null) {
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': {'facingMode': 'user'},
});
_localRenderer.srcObject = _localStream;
}
for (final t in _localStream!.getTracks()) {
await _pc!.addTrack(t, _localStream!);
}
}
Future<void> call() async {
if (!_connected) return _toast('请先连接信令服务器');
if (peerId.isEmpty) return _toast('请输入对方ID');
setState(() {
_incoming = false;
_inCall = true;
_status = 'calling...';
});
await _ensurePeer();
final offer = await _pc!.createOffer({});
await _pc!.setLocalDescription(offer);
_send({
'type': 'offer',
'from': myId,
'to': peerId,
'offer': {'type': offer.type, 'sdp': offer.sdp},
});
setState(() => _status = 'offer sent');
}
Future<void> accept() async {
if (_pendingOffer == null) return;
final from = _pendingOffer!['from'] as String? ?? '';
final offer = _pendingOffer!['offer'] as Map<String, dynamic>;
_peerIdCtrl.text = from; // 自动填对方ID
setState(() {
_incoming = false;
_inCall = true;
_status = 'accepting...';
});
await _ensurePeer();
await _pc!.setRemoteDescription(
RTCSessionDescription(offer['sdp'], offer['type']),
);
await _flushPendingCandidates();
final answer = await _pc!.createAnswer({});
await _pc!.setLocalDescription(answer);
_send({
'type': 'answer',
'from': myId,
'to': from,
'answer': {'type': answer.type, 'sdp': answer.sdp},
});
setState(() => _status = 'answer sent');
}
Future<void> _flushPendingCandidates() async {
if (_pc == null) return;
if (await _pc!.getRemoteDescription() == null) return;
for (final c in _pendingCandidates) {
await _pc!.addCandidate(c);
}
_pendingCandidates.clear();
}
Future<void> hangup() async {
// 通知对端
if (_connected && peerId.isNotEmpty) {
_send({'type': 'hangup', 'from': myId, 'to': peerId});
}
await _hangup(localOnly: true);
setState(() {
_incoming = false;
_inCall = false;
_status = 'hung up';
});
}
Future<void> _hangup({required bool localOnly}) async {
try {
await _pc?.close();
} catch (_) {}
_pc = null;
// 释放远端画面
_remoteRenderer.srcObject = null;
// 本地流可选择是否保留以便下一次更快(这里保留,避免频繁抢占)
// 如果你想每次挂断都释放摄像头,把下面注释取消:
// _localStream?.getTracks().forEach((t) => t.stop());
// _localStream = null;
// _localRenderer.srcObject = null;
_pendingCandidates.clear();
_pendingOffer = null;
setState(() {});
}
void _toast(String s) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(s)));
}
// ---------------- UI ----------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('RTC 1v1')),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(children: [
Expanded(
child: TextField(
controller: _myIdCtrl,
decoration: const InputDecoration(labelText: '我的ID'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _peerIdCtrl,
decoration: const InputDecoration(labelText: '对方ID'),
),
),
]),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
ElevatedButton(
onPressed: _connected ? null : connect,
child: const Text('连接服务器'),
),
ElevatedButton(
onPressed: (!_connected || _inCall) ? null : call,
child: const Text('发起通话'),
),
ElevatedButton(
onPressed: _incoming ? accept : null,
child: const Text('接听'),
),
ElevatedButton(
onPressed: _inCall ? hangup : null,
child: const Text('挂断'),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text('状态:$_status'),
),
const SizedBox(height: 10),
Expanded(
child: Row(
children: [
Expanded(
child: Container(
color: Colors.black,
child: RTCVideoView(_localRenderer, mirror: true),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
color: Colors.black,
child: RTCVideoView(_remoteRenderer),
),
),
],
),
),
],
),
),
);
}
}
最后的测试效果,现在APP和网页之间也能正常通话了!
