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

Elixir-MembraneWebRTC/React中MediaStream客户端显示异常求助

问题描述

我正在扩展Membrane Videoroom教程中的客户端,但无法让远程流在客户端显示——要么stream为null,要么stream非空但没有任何内容显示。

核心代码:useGala Hook

const useGala = roomId => {
    const { socket } = useContext(SocketContext);
    const permChannel = socket.channel(`room:${roomId}`);

    const [gala, setGala] = useState({
        channel: permChannel,
        localStream: null, 
        peers: [],
        streams: [],
        webrtcEngine: null,
        initialized: false,
    });

    const joinWebrtc = async (engine) => {
        engine.join({ displayName: "Will" });
    }

    const createLocalStream = async (engine) => {
        const localStream = await navigator.mediaDevices.getUserMedia(MEDIA_CONSTRAINTS);
        localStream.getTracks().forEach((track) => {
            engine.addTrack(track, localStream)
        });
        setGala(prev => ({ ...prev, localStream: localStream }));
    }

    const makeEngine = async () => {
        // Create the WebRTC engine
        const engine = new MembraneWebRTC({
            callbacks: {
                onSendMediaEvent: (mediaEvent) => {
                    console.log('gala:onSendMediaEvent: fired');
                    gala.channel.push("mediaEvent", { data: mediaEvent });
                },
                onConnectionError: () => {
                    console.log('on connection error triggered')
                },
                onJoinSuccess: (peerId, peersInRoom) => {
                    console.log("gala:onJoinSuccess: fired");
                    setGala(prev => ({ ...prev, peers: peersInRoom }));
                    createLocalStream(engine);
                },
                onJoinError: (metadata) => {
                    throw `Peer denied.`;
                },
                onTrackReady: ({ stream, peer, metadata }) => {
                    console.log("gala:onTrackReady: fired");
                    const inboundStream = { peerId: `${peer.id}`, stream: stream };
                    // This fires, but the `stream` object is either 
                    // 1. null if on the already joined client
                    // 2. non-null if on the newly joined client
                    setGala(prev => ({
                        ...prev,
                        streams: [...prev.streams, inboundStream]
                    }));
                },
                onTrackAdded: (ctx) => {
                    console.log("hook:onTrackAdded: fired");
                    console.log(ctx);
                },
                onTrackRemoved: (ctx) => { },
                onPeerJoined: (peer) => {
                    console.log("hook:onPeerJoined: fired");

                    setGala(prev => {
                        return {
                            ...prev,
                            peers: [...prev.peers, peer]
                        }
                    });
                },
                onPeerLeft: (peer) => {
                    console.log("hook:onPeerLeft: fired");

                    setGala(prev => {
                        const remainingPeers = prev.peers.filter((p) => p.id !== peer.id);
                        return {
                            ...prev,
                            peers: remainingPeers
                        }
                    });
                },
                onPeerUpdated: (ctx) => { },
            }
        });

        return engine;
    }

    const otherStuff = async (engine) => {
        if (gala.channel.state === 'joined' && !!engine) {
            // Setup a tracker for inbound media
            gala.channel.on("mediaEvent", (event) => {
                // For already-joined client, does not fire if new peer joins
                // For new peer, fires
                engine.receiveMediaEvent(event.data)
            });

            setGala(prev => ({
                ...prev,
                initialized: true,
                webrtcEngine: engine
            }))
        }
    }

    const doEverything = async () => {
        let engine;
        if (!gala.webrtcEngine) {
            engine = await makeEngine();
        }

        // Join the server with the new stream
        const phoenixChannelPushResult = async (push) => {
            return new Promise((resolve, reject) => {
                push
                    .receive("ok", (response) => resolve(response))
                    .receive("error", (response) => reject(response));
            });
        };

        if (gala.channel.state !== 'joining' && gala.channel.state !== 'joined' && (!!engine || gala.webrtcEngine)) {
            await phoenixChannelPushResult(gala.channel.join());
            if (engine) {
                await joinWebrtc(engine);
                return engine
            } else {
                return gala.webrtcEngine;
            }
        }

    }

    const all = useCallback(async () => {
        doEverything()
            .then(engine => otherStuff(engine));
    }, [gala.channel]);

    const doStuffWithChannel = useCallback(async () => {
        if (gala?.streams?.length > 0) {
            // Setup a tracker for inbound media
            // Set this up to see if it would fire, but nothing
            gala.channel.on("mediaEvent", (event) => {
                gala.webrtcEngine.receiveMediaEvent(event.data)
            });
        }
    }, [gala.streams, gala.channel]);

    useEffect(() => {
        if (!socket) return;
        all();
        doStuffWithChannel();
    }, [socket]);

    return gala;
}

媒体渲染组件:MediaElement

const MediaElement = ({ peerId, gala }) => {
    const audioRef = useRef();
    const videoRef = useRef();

    useEffect(() => {
        if (gala?.streams?.length > 0) {
            audioRef.current.srcObject = gala?.streams[gala?.streams?.length - 1]?.stream;
            videoRef.current.srcObject = gala?.streams[gala?.streams?.length - 1]?.stream;
        }
    }, [gala?.streams, gala.channel, gala.webrtcEngine]);

    return (
        <div className='col-span-full col-start-3 row-span-3' key={peerId}>
            <p className='text-white'>{peerId}</p>
            <audio id={`audio-${peerId}`} ref={audioRef} volume="true" autoPlay></audio>
            <video id={`video-${peerId}`} ref={videoRef} playsInline autoPlay></video>
        </div>
    );
}

export default MediaElement;

我认为错误与webRtcEngine的状态管理方式有关,因为这是与原生JavaScript示例的主要区别。但我不知道该如何修复,请问如何让MediaStreams正常显示?


解决方案

你的问题集中在闭包陷阱、事件监听重复注册、流匹配逻辑错误三个核心点,以下是分步修复方案:

1. 修复WebRTC引擎回调的闭包问题

makeEngine中创建的回调引用的是初始gala状态,导致gala.channel可能不是最新值。直接使用初始化时的permChannel替代:

onSendMediaEvent: (mediaEvent) => {
  console.log('gala:onSendMediaEvent: fired');
  permChannel.push("mediaEvent", { data: mediaEvent });
}

2. 确保mediaEvent监听器只注册一次

当前otherStuff和doStuffWithChannel重复注册监听,导致逻辑混乱。将监听移到doEverything中,仅在channel首次连接成功后注册:

const doEverything = async () => {
  let engine;
  if (!gala.webrtcEngine) {
    engine = await makeEngine();
  }

  const phoenixChannelPushResult = async (push) => {
    return new Promise((resolve, reject) => {
      push
        .receive("ok", (response) => resolve(response))
        .receive("error", (response) => reject(response));
    });
  };

  if (gala.channel.state !== 'joining' && gala.channel.state !== 'joined' && (!!engine || gala.webrtcEngine)) {
    await phoenixChannelPushResult(gala.channel.join());
    // 先移除旧监听,避免重复绑定
    gala.channel.off("mediaEvent");
    gala.channel.on("mediaEvent", (event) => {
      (engine || gala.webrtcEngine).receiveMediaEvent(event.data);
    });
    if (engine) {
      // 先创建本地流再加入房间,符合Membrane流程
      await createLocalStream(engine);
      await joinWebrtc(engine);
      setGala(prev => ({
        ...prev,
        initialized: true,
        webrtcEngine: engine
      }));
      return engine;
    } else {
      return gala.webrtcEngine;
    }
  }
}

同时删除otherStuff和doStuffWithChannel中的重复监听逻辑。

3. 修复MediaElement的流匹配逻辑

当前总是取streams最后一项,导致所有组件绑定同一个流。修改为根据peerId匹配对应流:

const MediaElement = ({ peerId, gala }) => {
  const audioRef = useRef();
  const videoRef = useRef();

  useEffect(() => {
    const targetStream = gala.streams.find(item => item.peerId === peerId)?.stream;
    if (targetStream) {
      audioRef.current.srcObject = targetStream;
      videoRef.current.srcObject = targetStream;
    }
  }, [gala.streams, peerId]);

  return (
    <div className='col-span-full col-start-3 row-span-3' key={peerId}>
      <p className='text-white'>{peerId}</p>
      <audio id={`audio-${peerId}`} ref={audioRef} autoPlay playsInline></audio>
      <video id={`video-${peerId}`} ref={videoRef} playsInline autoPlay muted={peerId === localPeerId}></video>
    </div>
  );
}

注:本地流建议添加muted属性避免回声,需要在父组件传入localPeerId

4. 修复TrackReady的空流问题

onTrackReady返回空流是因为轨道未完全就绪,改用onTrackAdded处理流的创建与更新:

onTrackAdded: ({ track, stream, peer }) => {
  console.log("hook:onTrackAdded: fired");
  setGala(prev => {
    // 检查是否已存在该peer的流
    const existingStreamIndex = prev.streams.findIndex(s => s.peerId === peer.id);
    if (existingStreamIndex !== -1) {
      // 已有流则直接添加轨道
      const updatedStreams = [...prev.streams];
      updatedStreams[existingStreamIndex].stream.addTrack(track);
      return { ...prev, streams: updatedStreams };
    } else {
      // 无对应流则新增
      return {
        ...prev,
        streams: [...prev.streams, { peerId: peer.id, stream }]
      };
    }
  });
}

同时删除onTrackReady中的流添加逻辑,避免重复。


内容的提问来源于stack exchange,提问作者nonamorando

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.21 17:45:30