Twilio Video 2.27.0:离开房间后摄像头灯不灭及房间复用问题
Twilio Video 2.27.0 核心问题修复方案
问题1:离开房间后摄像头指示灯持续亮起
问题原因
你在joinRoom方法中单独调用Video.createLocalVideoTrack()创建了本地视频轨道,但该轨道未关联到房间的本地参与者对象中。后续断开房间时的轨道停止操作仅处理了房间关联的轨道,遗漏了这个独立创建的轨道,导致摄像头资源未被释放。
修复方案
- 在组件状态中添加本地轨道引用,用于后续销毁:
this.state = { // 原有状态... localVideoTrack: null };
- 创建轨道时保存引用到状态:
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 }); // 保存轨道引用 });
- 在房间断开和组件卸载时,主动停止并清理该轨道:
修改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秒)才能检测到参与者离线,这段时间内房间会处于"僵尸"状态,无法正常加入。
修复方案
- 前端监听页面关闭事件,主动断开房间并清理资源:
添加页面关闭监听逻辑:
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()); } } }
- 后端配置房间自动销毁策略:
创建Twilio房间时设置空房间超时时间,当房间无参与者时自动销毁(Spring Boot示例):
RoomCreator roomCreator = Room.creator() .setUniqueName(roomId) .setEmptyRoomTimeout(10) // 空房间10秒后自动销毁 .setMaxParticipants(2); // 根据业务设置最大参与者数 Room room = roomCreator.create();
- 后端定期清理闲置房间:
通过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
相关产品推荐
相关产品推荐

