You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Peer.js+React视频聊天远程流黑屏问题排查求助

Peer.js + React 视频聊天远程流黑屏问题解决方案

问题描述

使用Peer.js和React开发简易视频聊天应用,呼叫接通后两端接收到的远程视频流均黑屏,尽管已将视频元素的srcObject设置为该流。排查细节:

  • 打印远程流及其轨道,得到包含音频和视频MediaStreamTrack的MediaStream对象,但视频轨道的capabilities仅含设备ID和空的facingMode数组,无宽高比或流尺寸信息。
  • 监听srcObject设置事件时,仅触发loadstart,loadedmetadata、loadeddata和canplay均未触发,readyState始终为0。

问题代码

const vidSenderRef = useRef(null);
const vidReceiverRef = useRef(null);
const containerRef = useRef(null);
const [user, setUser] = useContext(userContext);
const [me, setMe] = useState(null);
const [stream, setStream] = useState(null);
const [isCalling, setIsCalling] = useState(false);

async function askUserMedia() {
    let stream = null;
    try {
        stream = await navigator.mediaDevices.getUserMedia({
            video: {
                facingMode: "user",
            }, audio: false
        })
    }
    catch (err) {
        if (err.message === "Device in use") {
            const devices = await navigator.mediaDevices.enumerateDevices();
            const videoDevices = devices.filter(device => device.label.includes("OBS"));
            const video = videoDevices[0].deviceId;
            stream = await navigator.mediaDevices.getUserMedia({
                video: {
                    deviceId: {
                        exact: video
                    },
                    facingMode: "user",
                }, audio: false
            })
        }
        else {
            toastError("Error" + err.message)
        }
    }
    setStream(stream);
    vidSenderRef.current.srcObject = stream;
}

function assignPeer() {
    const peer = new Peer({
        host: "localhost",
        port: 9000,
        path: "/",
        secure: true,
        // debug: 3,
        config: {
            'iceServers': [
                { url: 'stun:stun01.sipphone.com' },
                {
                    url: 'turn:192.158.29.39:3478?transport=tcp',
                    credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
                    username: '28224511:1379330808'
                },
                {
                    url: 'turn:turn.bistri.com:80',
                    credential: 'homeo',
                    username: 'homeo'
                },
            ]
        },
    })
    return peer;
}

async function callUser(ID) {
    const call = me.call(ID, stream);
    call.on("stream", (userVideoStream) => {
        vidReceiverRef.current.srcObject = userVideoStream;
        console.log(userVideoStream.getVideoTracks()[0].getCapabilities());
        vidReceiverRef.current.play()
    });
    setIsCalling(true);
}

async function answerCall() {
    me.on("call", (call) => {
        call.answer(stream);
        call.on("stream", (userVideoStream) => {
            vidReceiverRef.current.srcObject = userVideoStream;
            // vidReceiverRef.current.play();
        });
    });
    setIsCalling(true);
}

useEffect(() => {
    Cookies.set("currentRoom", "00ewRc");
    Socket.emit("join", {
        sender: user.ID,
        receiver: user.ID === 1 ? 2 : 1,
    });
    askUserMedia();
    setMe(assignPeer());
}, []);

useEffect(() => {
    // caller side
    if (!me || !stream) return;
    if (props.caller === user.ID) {
        Socket.emit("handshake",
            {
                caller: user.ID,
                callee: props.callee,
                isCallerHandshake: true,
                room: Cookies.get("currentRoom")
            });
    }

    // callee side
    if (props.callee === user.ID && !isCalling) {
        answerCall();
    }

    Socket.on("handshake", (data) => {
        if (data.isCallerHandshake && data.callee === user.ID) {
            Socket.emit("handshake", {
                caller: data.caller,
                callee: user.ID,
                isCalleeHandshake: true,
                ID: me.id,
                room: Cookies.get("currentRoom")
            })
        }
        else if (data.isCalleeHandshake && data.caller === user.ID && !isCalling) {
            callUser(data.ID);
        }
    })
}, [me, stream]);

useEffect(() => {
    vidReceiverRef.current.play();
}, [])

修复步骤

1. 等待Peer实例完全初始化

Peer初始化是异步操作,需等待其获取到服务器分配的ID后再使用,否则会导致呼叫逻辑异常:

function assignPeer() {
    return new Promise((resolve) => {
        const peer = new Peer({
            host: "localhost",
            port: 9000,
            path: "/",
            secure: window.location.protocol === "https:", // 随当前协议自动切换
            config: {
                'iceServers': [
                    { urls: 'stun:stun.l.google.com:19302' }, // 替换为稳定的公共STUN服务器
                    // 替换为有效的TURN服务器(若需要)
                    {
                        urls: 'turn:your-turn-server.com:3478',
                        credential: 'your-credential',
                        username: 'your-username'
                    }
                ]
            },
        });
        peer.on('open', (id) => resolve(peer));
        peer.on('error', (err) => {
            toastError("Peer初始化失败: " + err.message);
            resolve(null);
        });
    });
}

// 修改初始化useEffect
useEffect(() => {
    Cookies.set("currentRoom", "00ewRc");
    Socket.emit("join", {
        sender: user.ID,
        receiver: user.ID === 1 ? 2 : 1,
    });
    askUserMedia();
    // 异步初始化Peer
    assignPeer().then(peerInstance => {
        if(peerInstance) setMe(peerInstance);
    });
}, []);

2. 修复重复绑定的呼叫监听

原answerCall每次调用都会新增call事件监听器,导致重复处理。改为单次绑定并在组件卸载时清理:

useEffect(() => {
    if (!me || !stream) return;
    const handleIncomingCall = (call) => {
        call.answer(stream);
        call.on("stream", (userVideoStream) => {
            if (!vidReceiverRef.current) return;
            vidReceiverRef.current.srcObject = userVideoStream;
            // 等待元数据加载完成后再播放
            vidReceiverRef.current.onloadedmetadata = () => {
                vidReceiverRef.current.play().catch(err => {
                    console.error("自动播放失败,请手动触发:", err);
                });
            };
        });
    };
    me.on("call", handleIncomingCall);
    // 组件卸载时清理监听器
    return () => me.off("call", handleIncomingCall);
}, [me, stream]);

3. 安全操作视频元素

操作视频元素前先判断是否存在,避免空指针错误,并处理播放异常:

// 修改callUser中的流处理逻辑
async function callUser(ID) {
    const call = me.call(ID, stream);
    call.on("stream", (userVideoStream) => {
        if (!vidReceiverRef.current) return;
        vidReceiverRef.current.srcObject = userVideoStream;
        vidReceiverRef.current.onloadedmetadata = () => {
            vidReceiverRef.current.play().catch(err => {
                console.error("播放失败:", err);
            });
        };
    });
    setIsCalling(true);
}

4. 删除无效的播放钩子

最后一个直接调用vidReceiverRef.current.play()的useEffect无意义,此时srcObject尚未设置,直接删除即可。

验证修复

完成修改后重新测试:

  • 确认Peer实例成功获取ID后再发起呼叫
  • 检查远程流的readyState是否变为1(HAVE_METADATA)或更高
  • 确认视频元素的loadedmetadata事件正常触发

内容的提问来源于stack exchange,提问作者Hải Đỗ

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 14:22:23