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

Twilio Video 2.27.0:离开房间后摄像头灯不灭及房间复用问题

Twilio Video 2.27.0 核心问题修复方案

问题1:离开房间后摄像头指示灯持续亮起

问题原因

你在joinRoom方法中单独调用Video.createLocalVideoTrack()创建了本地视频轨道,但该轨道未关联到房间的本地参与者对象中。后续断开房间时的轨道停止操作仅处理了房间关联的轨道,遗漏了这个独立创建的轨道,导致摄像头资源未被释放。

修复方案

  1. 在组件状态中添加本地轨道引用,用于后续销毁:
this.state = {
  // 原有状态...
  localVideoTrack: null
};
  1. 创建轨道时保存引用到状态:
Video.createLocalVideoTrack().then(track => {
  const localMedia = this.localMediaRef.current;
  localMedia.appendChild(track.attach());
  localMedia.querySelector("div.label").innerHTML = room.localParticipant.identity;
  this.setState({ localVideoTrack: track }); // 保存轨道引用
});
  1. 在房间断开和组件卸载时,主动停止并清理该轨道:
    修改room.on('disconnected')事件逻辑:
room.on('disconnected', () => {
  console.log(`Disconnected from Room ${room.name}`);
  
  // 停止并清理房间本地参与者的所有轨道
  room.localParticipant.tracks.forEach(publication => {
    if (publication.track) {
      publication.track.stop();
      publication.track.detach().forEach(el => el.remove());
      room.localParticipant.unpublishTrack(publication.track);
    }
  });

  // 停止独立创建的本地视频轨道
  if (this.state.localVideoTrack) {
    this.state.localVideoTrack.stop();
    this.state.localVideoTrack.detach().forEach(el => el.remove());
    this.setState({ localVideoTrack: null });
  }

  this.setState({ 
    hasJoinedRoom: false, 
    localMediaAvailable: false, 
    remoteMediaAvailable: false, 
    remoteTracks: [],
    room: null
  });
});

同时完善componentWillUnmount逻辑:

componentWillUnmount() {
  window.removeEventListener('beforeunload', this.handleBeforeUnload);
  if (this.state.room) {
    this.state.room.disconnect();
  }
  // 兜底清理本地轨道
  if (this.state.localVideoTrack) {
    this.state.localVideoTrack.stop();
    this.state.localVideoTrack.detach().forEach(el => el.remove());
  }
}

问题2:用户直接关闭页面后房间无法复用

问题原因

用户直接关闭页面时,前端未主动触发房间断开操作,Twilio需通过心跳超时(默认约30秒)才能检测到参与者离线,这段时间内房间会处于"僵尸"状态,无法正常加入。

修复方案

  1. 前端监听页面关闭事件,主动断开房间并清理资源:
    添加页面关闭监听逻辑:
componentDidMount() {
  window.addEventListener('beforeunload', this.handleBeforeUnload);
}

handleBeforeUnload() {
  if (this.state.room) {
    this.state.room.disconnect();
    // 清理所有本地轨道
    this.state.room.localParticipant.tracks.forEach(publication => {
      if (publication.track) {
        publication.track.stop();
        publication.track.detach().forEach(el => el.remove());
      }
    });
    if (this.state.localVideoTrack) {
      this.state.localVideoTrack.stop();
      this.state.localVideoTrack.detach().forEach(el => el.remove());
    }
  }
}
  1. 后端配置房间自动销毁策略:
    创建Twilio房间时设置空房间超时时间,当房间无参与者时自动销毁(Spring Boot示例):
RoomCreator roomCreator = Room.creator()
    .setUniqueName(roomId)
    .setEmptyRoomTimeout(10) // 空房间10秒后自动销毁
    .setMaxParticipants(2); // 根据业务设置最大参与者数
Room room = roomCreator.create();
  1. 后端定期清理闲置房间:
    通过Twilio API定期查询房间状态,清理超过设定时长的闲置房间,避免僵尸房间堆积。

完整修改后的组件代码

import React, { Component } from 'react';
import Video from 'twilio-video';
import Button from '@mui/material/Button';
import { getToken, getUserDetails } from '../_globals/UserFunctions/UserFunctions';

class VideoChat extends Component {
  constructor(props) {
    super(props);
  
    this.state = {
      identity: "",
      roomId: null,
      username: '',
      localMediaAvailable: true,
      hasJoinedRoom: false,
      remoteMediaAvailable: false,
      remoteTracks: [],
      room: null,
      localVideoTrack: null
    };
  
    this.joinRoom = this.joinRoom.bind(this);
    this.leaveRoom = this.leaveRoom.bind(this);
    this.handleUsernameChange = this.handleUsernameChange.bind(this);
    this.handleRoomChange = this.handleRoomChange.bind(this);
    this.handleBeforeUnload = this.handleBeforeUnload.bind(this);

    this.localMediaRef = React.createRef();
    this.remoteMediaRef = React.createRef();    
  }  

  componentDidMount() {
    window.addEventListener('beforeunload', this.handleBeforeUnload);
  }

  componentWillUnmount() {
    window.removeEventListener('beforeunload', this.handleBeforeUnload);
    if (this.state.room) {
      this.state.room.disconnect();
    }
    if (this.state.localVideoTrack) {
      this.state.localVideoTrack.stop();
      this.state.localVideoTrack.detach().forEach(el => el.remove());
    }
  }

  handleBeforeUnload() {
    if (this.state.room) {
      this.state.room.disconnect();
      this.state.room.localParticipant.tracks.forEach(publication => {
        if (publication.track) {
          publication.track.stop();
          publication.track.detach().forEach(el => el.remove());
        }
      });
      if (this.state.localVideoTrack) {
        this.state.localVideoTrack.stop();
        this.state.localVideoTrack.detach().forEach(el => el.remove());
      }
    }
  }

  handleUsernameChange(event) {
    this.setState({ username: event.target.value });
  }
  handleRoomChange(event) {
    this.setState({ roomId: event.target.value });
  }
  
  startComponent() {
    const userId = getUserDetails().id;
    fetch(`http://localhost:9000/api/twilio/token?userId=${userId}`, {
      headers: {
        'Authorization': 'Bearer ' + getToken()
        }})
      .then(res => res.json())
      .then(data => {
        Video.connect(data.token, { name: this.state.roomId }).then(this.joinRoom, error => console.error(`Unable to connect to Room: ${error.message}`));
        this.setState({ identity: userId });
      });
  }

  joinRoom(room) {
    this.setState({
      room: room,
      localMediaAvailable: true,
      hasJoinedRoom: true
    });

    Video.createLocalVideoTrack().then(track => {
      const localMedia = this.localMediaRef.current;
      localMedia.appendChild(track.attach());
      localMedia.querySelector("div.label").innerHTML = room.localParticipant.identity;
      this.setState({ localVideoTrack: track });
    });

    console.log(`Joined as ${this.state.identity}`);

    room.on('participantConnected', participant => {
      console.log(`Participant ${participant.identity} has joined the room.`);
      this.setState({ remoteMediaAvailable: true });
      participant.tracks.forEach(publication => {
        if (publication.isSubscribed) {
          this.trackSubscribed(publication.track);
        }
      });
      participant.on('trackSubscribed', track => {
        this.trackSubscribed(track);
      });
    });

    room.on('participantDisconnected', participant => {
      console.log(`Participant ${participant.identity} has left the room.`);
      this.setState({ remoteMediaAvailable: false, remoteTracks: [] });
      const remoteMedia = this.remoteMediaRef.current;
      remoteMedia.innerHTML = '';

      console.log("---------participantDisconnected", participant)
    });
    

    room.participants.forEach(participant => {
      console.log(`Participant ${participant.identity} is connected to the room.`);
      this.setState({ remoteMediaAvailable: true });
      participant.tracks.forEach(publication => {
        if (publication.isSubscribed) {
          this.trackSubscribed(publication.track);
        }
      });
      participant.on('trackSubscribed', track => {
        const remoteMedia = this.remoteMediaRef.current;
        remoteMedia.appendChild(track.attach());
        remoteMedia.querySelector("div.label").innerHTML = this.getRemoteLabel(room);
      });
    });

    room.on('disconnected', () => {
      console.log(`Disconnected from Room ${room.name}`);
      
      room.localParticipant.tracks.forEach(publication => {
        if (publication.track) {
          publication.track.stop();
          publication.track.detach().forEach(el => el.remove());
          room.localParticipant.unpublishTrack(publication.track);
        }
      });

      if (this.state.localVideoTrack) {
        this.state.localVideoTrack.stop();
        this.state.localVideoTrack.detach().forEach(el => el.remove());
        this.setState({ localVideoTrack: null });
      }

      this.setState({ 
        hasJoinedRoom: false, 
        localMediaAvailable: false, 
        remoteMediaAvailable: false, 
        remoteTracks: [],
        room: null
      });
    });
  }

  getRemoteLabel(room) {
    let identity = "";
    for (let [key] of room.participants) {
      identity = room.participants.get(key).identity;
    }
    return identity;
  }

  trackSubscribed(track) {
    const existingMediaElement = document.getElementById(`${track.kind}-${track.sid}`);
    if (existingMediaElement) {
      existingMediaElement.appendChild(track.attach());
    } else {
      const mediaElement = document.createElement('div');
      mediaElement.id = `${track.kind}-${track.sid}`;
      mediaElement.appendChild(track.attach());
  
      const remoteMedia = this.remoteMediaRef.current;
      remoteMedia.appendChild(mediaElement);
      remoteMedia.querySelector("div.label").innerHTML = this.getRemoteLabel(this.state.room);
  
      this.setState({
        remoteTracks: [...this.state.remoteTracks, track]
      });
    }
  }
  

  leaveRoom() {
    const userId = getUserDetails().id;
    const url = `http://localhost:9000/api/twilio/room?userId=${userId}`;
  
    fetch(url, {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${getToken()}`
      }
    })
    .then(res => res.json())
    .then(() => {
      if (this.state.room) {
        this.state.room.disconnect();
      }
      this.setState({
        hasJoinedRoom: false,
        localMediaAvailable: false,
        remoteMediaAvailable: false,
        remoteTracks: [],
        room: null
      });
    });
  }
  

  render() {
    return (
      <div>
        <Button
          variant="contained"
          color="primary"
          onClick={() => this.startComponent()}>
            Join Room
        </Button>

        {this.state.hasJoinedRoom ? (
          <div>
            <Button 
              variant="contained"
              color="primary"
              onClick={this.leaveRoom}>
                Leave Room
            </Button>

            <div className="feed feed1" id="remote-media-div" ref={this.remoteMediaRef}><div className="label"></div></div>

            {this.state.localMediaAvailable ? (
              <div className="feed feed2" id="local-media-div" ref={this.localMediaRef}><div className="label"></div></div>
            ) : (
              <p>Local media not available.</p>
            )}
          
          </div>
        ) : (
          <p>Joining room...</p>
        )}
      </div>
    );
  }
}
export default VideoChat;

内容的提问来源于stack exchange,提问作者The Old County

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.25 19:14:53