1V1音视频对话4--FLUTTER实现

先把 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 端

  1. 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
  1. 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(),
    );
  }
}
  1. 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和网页之间也能正常通话了!

相关推荐
空白诗3 小时前
基础入门 Flutter for OpenHarmony:Flexible 弹性布局组件详解
flutter
阿林来了3 小时前
Flutter三方库适配OpenHarmony【flutter_speech】— FlutterPlugin 接口适配
flutter·harmonyos
张张说点啥3 小时前
能做影视级可商业视频的AI工具,Seedance 2.0 全球首发实测
人工智能·音视频
空白诗3 小时前
基础入门 Flutter for OpenHarmony:IndexedStack 索引堆叠组件详解
flutter
阿林来了3 小时前
Flutter三方库适配OpenHarmony【flutter_speech】— Core Speech Kit 概述
flutter·harmonyos
qq_433502184 小时前
收集了一些免费视频背景映月素材网站分享记录
经验分享·学习·音视频·生活
松叶似针4 小时前
Flutter三方库适配OpenHarmony【secure_application】— Window 管理与 getLastWindow API
flutter·harmonyos
空白诗4 小时前
基础入门 Flutter for OpenHarmony:Transform 变换组件详解
flutter
空白诗4 小时前
基础入门 Flutter for OpenHarmony:DecoratedBox 装饰盒子组件详解
flutter