2026-04-29 15:18:30 +08:00
import { mockCallSession } from './models.js' ;
2026-05-24 01:54:47 +08:00
import { RenderStreaming } from "../../module/renderstreaming.js" ;
import { getServerConfig , getRTCConfiguration } from "../js/config.js" ;
import { showNotification , generateId } from './utils.js' ;
2026-04-29 15:18:30 +08:00
import chatMessage from './chatmessage.js' ;
2026-05-24 01:54:47 +08:00
import { DEFAULT _PARTICIPANT _AVATAR , DEFAULT _PARTICIPANT _NAME , buildParticipantsSyncData , omitParticipant , removeParticipant , upsertParticipant } from './participants.js' ;
import { AUDIO _CONFIG , VAD _CONFIG , VIDEO _ONLY _CONSTRAINT , buildVideoConstraints , getAdaptiveVideoBitrate , getResolutionLabel , getTargetResolutionBitrate } from './media-config.js' ;
2026-05-24 01:29:34 +08:00
import { buildStatsLogPayload , createAudioAnalyser , getAudioLevel } from './media-monitoring.js' ;
2026-05-24 01:54:47 +08:00
import { bindInviteSocketEvents , buildSocketUserInfoPayload , createSignalingInstance , ensureSignalingStarted , getActiveSignalingInstance , sendInviteSignal , sendSocketUserInfo } from './signaling-session.js' ;
2026-05-24 01:01:28 +08:00
import { getNetworkQualityFromSummary , summarizeInboundStats } from './webrtc-stats.js' ;
2026-04-29 15:18:30 +08:00
class CallStateManager {
constructor ( ) {
this . state = {
id : generateId ( ) ,
session : {
... mockCallSession ,
2026-05-24 01:54:47 +08:00
status : 'idle'
2026-04-29 15:18:30 +08:00
} ,
2026-05-24 01:54:47 +08:00
localStream : null ,
remoteStream : null ,
remoteStreams : { } ,
participants : { }
2026-04-29 15:18:30 +08:00
} ;
this . listeners = [ ] ;
2026-05-18 23:03:28 +08:00
this . socketEventHandlers = { } ;
2026-05-24 01:46:57 +08:00
this . _inviteEventSignaling = null ;
2026-04-29 15:18:30 +08:00
}
subscribe ( callback ) {
this . listeners . push ( callback ) ;
return ( ) => {
this . listeners = this . listeners . filter ( cb => cb !== callback ) ;
} ;
}
notify ( changes ) {
this . listeners . forEach ( cb => cb ( this . state , changes ) ) ;
}
async init ( ) {
await this . setupConfig ( ) ;
this . loadUserSettings ( ) ;
await this . getLocalStream ( ) ;
}
loadUserSettings ( ) {
const userSettings = localStorage . getItem ( 'userSettings' ) ;
if ( userSettings ) {
try {
const settings = JSON . parse ( userSettings ) ;
if ( settings . name || settings . avatar ) {
this . state . session . localUser = {
... this . state . session . localUser ,
id : settings . userId || this . state . session . localUser . id ,
name : settings . name || this . state . session . localUser . name ,
avatar : settings . avatar || this . state . session . localUser . avatar
} ;
this . notify ( { type : 'USER_SETTINGS_UPDATED' , user : this . state . session . localUser } ) ;
}
if ( settings . resolution ) {
this . _savedResolution = settings . resolution ;
2026-05-24 01:54:47 +08:00
console . log ( ` 已恢复分辨率设置: ${ settings . resolution . width } x ${ settings . resolution . height } ` ) ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error loading user settings:' , error ) ;
}
}
}
async setupConfig ( ) {
const res = await getServerConfig ( ) ;
this . useWebSocket = res . useWebSocket ;
}
async getLocalStream ( ) {
try {
console . log ( 'Requesting camera permission...' ) ;
if ( ! navigator . mediaDevices || ! navigator . mediaDevices . getUserMedia ) {
console . error ( 'getUserMedia is not supported' ) ;
throw new Error ( 'getUserMedia is not supported' ) ;
}
2026-05-24 01:01:28 +08:00
const videoConstraints = buildVideoConstraints ( this . _savedResolution ) ;
2026-04-29 15:18:30 +08:00
const stream = await navigator . mediaDevices . getUserMedia ( {
video : videoConstraints ,
audio : AUDIO _CONFIG
} ) ;
console . log ( 'Stream obtained successfully:' , stream ) ;
console . log ( 'Video tracks:' , stream . getVideoTracks ( ) ) ;
console . log ( 'Audio tracks:' , stream . getAudioTracks ( ) ) ;
this . state . localStream = stream ;
this . state . session . localUser . mediaState . video = true ;
this . state . session . localUser . mediaState . audio = true ;
console . log ( 'Local stream stored, notifying UI...' ) ;
this . notify ( { type : 'LOCAL_STREAM_OBTAINED' , stream } ) ;
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType : 'video' , value : true } ) ;
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType : 'audio' , value : true } ) ;
this . emitMediaStateChange ( ) ;
this . startActivityDetection ( this . state . localStream , { isLocal : true } ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error getting local stream:' , error ) ;
this . state . session . localUser . mediaState . video = false ;
this . state . session . localUser . mediaState . audio = false ;
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType : 'video' , value : false } ) ;
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType : 'audio' , value : false } ) ;
}
}
async updateLocalMedia ( mediaType , value ) {
2026-05-24 01:18:27 +08:00
await this . _updateLocalMediaRefactored ( mediaType , value ) ;
return ;
}
async _updateLocalMediaRefactored ( mediaType , value ) {
2026-04-29 15:18:30 +08:00
if ( mediaType === 'video' && value ) {
2026-05-24 01:18:27 +08:00
await this . _enableLocalVideo ( ) ;
this . _notifyUserListUpdate ( ) ;
return ;
}
this . state . session . localUser . mediaState [ mediaType ] = value ;
this . _notifyLocalMediaChange ( mediaType , value ) ;
this . emitMediaStateChange ( ) ;
if ( mediaType === 'video' && ! value ) {
this . _disableLocalVideoTracks ( ) ;
}
if ( mediaType === 'audio' ) {
this . _setLocalAudioTrackEnabled ( value ) ;
}
this . _notifyUserListUpdate ( ) ;
}
async _enableLocalVideo ( ) {
try {
const newVideoTrack = await this . _requestNewVideoTrack ( ) ;
this . _replaceLocalVideoTrack ( newVideoTrack ) ;
await this . _updateOutgoingVideoTrack ( newVideoTrack ) ;
this . state . session . localUser . mediaState . video = true ;
this . notify ( { type : 'LOCAL_STREAM_OBTAINED' , stream : this . state . localStream } ) ;
this . _notifyLocalMediaChange ( 'video' , true ) ;
2026-04-29 15:18:30 +08:00
this . emitMediaStateChange ( ) ;
2026-05-24 01:18:27 +08:00
this . startActivityDetection ( this . state . localStream , { isLocal : true } ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-05-24 01:18:27 +08:00
console . error ( 'Error reopening video:' , error ) ;
this . state . session . localUser . mediaState . video = false ;
this . _notifyLocalMediaChange ( 'video' , false ) ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:18:27 +08:00
}
async _requestNewVideoTrack ( ) {
const newVideoStream = await navigator . mediaDevices . getUserMedia ( VIDEO _ONLY _CONSTRAINT ) ;
const newVideoTrack = newVideoStream . getVideoTracks ( ) [ 0 ] ;
if ( ! newVideoTrack ) {
throw new Error ( 'Failed to get video track' ) ;
}
return newVideoTrack ;
}
_replaceLocalVideoTrack ( newVideoTrack ) {
if ( this . state . localStream ) {
const oldVideoTracks = this . state . localStream . getVideoTracks ( ) ;
oldVideoTracks . forEach ( track => {
track . stop ( ) ;
this . state . localStream . removeTrack ( track ) ;
2026-04-29 15:18:30 +08:00
} ) ;
2026-05-24 01:18:27 +08:00
this . state . localStream . addTrack ( newVideoTrack ) ;
return ;
}
this . state . localStream = new MediaStream ( [ newVideoTrack ] ) ;
}
async _updateOutgoingVideoTrack ( newVideoTrack ) {
if ( ! this . renderstreaming ) {
return ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:18:27 +08:00
console . log ( 'Updating video track in WebRTC connection' ) ;
if ( this . role === 'host' ) {
const participantIds = Object . keys ( this . state . remoteStreams ) ;
for ( const participantId of participantIds ) {
await this . _updateVideoTrackForPeer ( newVideoTrack , participantId ) ;
}
return ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:18:27 +08:00
await this . _updateVideoTrackForPeer ( newVideoTrack ) ;
}
async _updateVideoTrackForPeer ( newVideoTrack , participantId = undefined ) {
const transceivers = this . renderstreaming . getTransceivers ( participantId ) ;
if ( ! transceivers ) {
return ;
}
2026-05-24 01:54:47 +08:00
const videoTransceivers = transceivers . filter ( transceiver => transceiver . sender && transceiver . sender . track && transceiver . sender . track . kind === 'video' ) ;
2026-05-24 01:18:27 +08:00
if ( videoTransceivers . length > 0 ) {
await this . _replaceVideoTrackOnTransceivers ( videoTransceivers , newVideoTrack , participantId ) ;
2026-05-24 01:54:47 +08:00
}
else {
2026-05-24 01:18:27 +08:00
this . _addVideoTransceiver ( newVideoTrack , participantId ) ;
}
this . _scheduleVideoSenderUpdate ( participantId ) ;
}
async _replaceVideoTrackOnTransceivers ( videoTransceivers , newVideoTrack , participantId ) {
for ( const transceiver of videoTransceivers ) {
try {
await transceiver . sender . replaceTrack ( newVideoTrack ) ;
console . log ( participantId
? ` Replaced video track for participant ${ participantId } `
: 'Successfully replaced video track' ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
console . error ( participantId
? ` Error replacing video track for ${ participantId } : `
: 'Error replacing video track:' , error ) ;
2026-05-24 01:18:27 +08:00
}
}
}
_addVideoTransceiver ( newVideoTrack , participantId ) {
try {
if ( participantId ) {
this . renderstreaming . addTransceiver ( newVideoTrack , { direction : 'sendonly' } , participantId ) ;
console . log ( ` Added new video transceiver for participant ${ participantId } ` ) ;
return ;
}
this . renderstreaming . addTransceiver ( newVideoTrack , { direction : 'sendonly' } ) ;
console . log ( 'Added new video transceiver' ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
console . error ( participantId
? ` Error adding video transceiver for ${ participantId } : `
: 'Error adding video transceiver:' , error ) ;
2026-05-24 01:18:27 +08:00
}
}
_scheduleVideoSenderUpdate ( participantId ) {
setTimeout ( ( ) => { this . setCodecPreferences ( participantId ) ; } , 100 ) ;
setTimeout ( ( ) => { this . setVideoEncodingParameters ( participantId ) ; } , 200 ) ;
}
_disableLocalVideoTracks ( ) {
if ( ! this . state . localStream ) {
return ;
}
this . state . session . localUser . mediaState . video = false ;
this . state . localStream . getTracks ( ) . forEach ( track => {
if ( track . kind === 'video' ) {
track . stop ( ) ;
}
} ) ;
}
_setLocalAudioTrackEnabled ( value ) {
if ( ! this . state . localStream ) {
return ;
}
this . state . session . localUser . mediaState . audio = value ;
this . state . localStream . getTracks ( ) . forEach ( track => {
if ( track . kind === 'audio' ) {
track . enabled = value ;
}
} ) ;
}
_notifyLocalMediaChange ( mediaType , value ) {
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType , value } ) ;
}
_notifyUserListUpdate ( ) {
this . notify ( {
type : 'USER_LIST_UPDATE' ,
localUser : this . state . session . localUser ,
remoteUser : this . state . session . remoteUser
} ) ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:46:57 +08:00
onSocketEvent ( eventName , handler ) {
this . socketEventHandlers [ eventName ] = handler ;
}
2026-05-16 21:26:19 +08:00
async connectSignaling ( ) {
await this . setupConfig ( ) ;
2026-05-24 01:46:57 +08:00
const { signaling , reused } = await ensureSignalingStarted ( this . _signaling , this . useWebSocket ) ;
this . _signaling = signaling ;
2026-05-24 01:54:47 +08:00
this . _inviteEventSignaling = bindInviteSocketEvents ( this . _signaling , this . socketEventHandlers , this . _inviteEventSignaling ) ;
2026-05-24 01:46:57 +08:00
if ( reused ) {
2026-05-16 21:26:19 +08:00
console . log ( 'Signaling already connected, reusing existing instance' ) ;
return this . _signaling ;
}
console . log ( 'Signaling connected (WebSocket only, no room yet)' ) ;
return this . _signaling ;
}
2026-05-18 23:03:28 +08:00
getActiveSignaling ( ) {
2026-05-24 01:46:57 +08:00
return getActiveSignalingInstance ( this . _signaling , this . renderstreaming ) ;
2026-05-18 23:03:28 +08:00
}
sendInviteCall ( payload ) {
2026-05-24 01:46:57 +08:00
sendInviteSignal ( this . getActiveSignaling ( ) , 'sendInviteCall' , payload ) ;
2026-05-18 23:03:28 +08:00
}
sendInviteAccepted ( payload ) {
2026-05-24 01:46:57 +08:00
sendInviteSignal ( this . getActiveSignaling ( ) , 'sendInviteAccepted' , payload ) ;
2026-05-18 23:03:28 +08:00
}
sendInviteRejected ( payload ) {
2026-05-24 01:46:57 +08:00
sendInviteSignal ( this . getActiveSignaling ( ) , 'sendInviteRejected' , payload ) ;
2026-05-18 23:03:28 +08:00
}
2026-05-16 23:07:08 +08:00
syncSocketUserInfo ( userInfo = null ) {
2026-05-24 01:46:57 +08:00
const payload = buildSocketUserInfoPayload ( userInfo , this . state . session . localUser ) ;
2026-05-16 23:07:08 +08:00
this . state . session . localUser = {
... this . state . session . localUser ,
id : payload . id ,
name : payload . name ,
avatar : payload . avatar
} ;
2026-05-24 01:46:57 +08:00
sendSocketUserInfo ( this . getActiveSignaling ( ) , payload ) ;
2026-05-16 23:07:08 +08:00
}
2026-04-29 15:18:30 +08:00
async _createSignalingAndRTC ( connectionId ) {
2026-05-24 01:46:57 +08:00
this . connectionId = connectionId ;
2026-04-29 15:18:30 +08:00
this . state . session . status = 'connecting' ;
this . notify ( { type : 'CALL_STATUS_CHANGE' , status : 'connecting' } ) ;
if ( ! this . state . localStream ) {
console . log ( 'Local stream not available, waiting for initialization...' ) ;
await new Promise ( ( resolve ) => {
const checkStream = ( ) => {
if ( this . state . localStream ) {
resolve ( ) ;
2026-05-24 01:54:47 +08:00
}
else {
2026-04-29 15:18:30 +08:00
setTimeout ( checkStream , 100 ) ;
}
} ;
checkStream ( ) ;
} ) ;
}
2026-05-24 01:46:57 +08:00
const signaling = this . _signaling || createSignalingInstance ( this . useWebSocket ) ;
const config = getRTCConfiguration ( ) ;
2026-04-29 15:18:30 +08:00
this . renderstreaming = new RenderStreaming ( signaling , config ) ;
2026-05-24 01:46:57 +08:00
this . _signaling = null ;
2026-04-29 15:18:30 +08:00
}
async setUp ( connectionId ) {
await this . _createSignalingAndRTC ( connectionId ) ;
this . _registerCallbacks ( ) ;
await this . _startConnection ( connectionId ) ;
}
_registerCallbacks ( ) {
this . renderstreaming . onNewPeer = ( participantId ) => {
console . log ( ` New peer created for ${ participantId } , adding local tracks ` ) ;
if ( this . state . localStream ) {
const tracks = this . state . localStream . getTracks ( ) ;
for ( const track of tracks ) {
this . renderstreaming . addTransceiver ( track , { direction : 'sendonly' } , participantId ) ;
}
this . setCodecPreferences ( participantId ) ;
this . setVideoEncodingParameters ( participantId ) ;
}
} ;
this . renderstreaming . onConnect = ( connectionId , data ) => {
if ( data && data . role ) {
this . role = data . role ;
this . state . session . localUser . isHost = ( this . role === 'host' ) ;
if ( data . participantId ) {
this . selfParticipantId = data . participantId ;
}
console . log ( ` Connected as ${ this . role } , participantId: ${ this . selfParticipantId } ` ) ;
}
this . state . session . status = 'ongoing' ;
this . notify ( { type : 'CALL_STATUS_CHANGE' , status : 'ongoing' } ) ;
if ( this . role === 'participant' ) {
if ( this . state . localStream ) {
this . state . localStream . getAudioTracks ( ) . forEach ( track => {
track . enabled = false ;
} ) ;
}
this . state . session . localUser . mediaState . audio = false ;
this . notify ( { type : 'LOCAL_MEDIA_CHANGE' , mediaType : 'audio' , value : false } ) ;
console . log ( 'Participant joined with audio muted by default' ) ;
}
this . sendMessage ( 'user-info' , {
id : this . state . session . localUser . id ,
name : this . state . session . localUser . name ,
avatar : this . state . session . localUser . avatar
} ) ;
this . emitMediaStateChange ( ) ;
if ( this . state . localStream ) {
this . showStatsMessage ( ) ;
2026-05-24 01:54:47 +08:00
}
else {
2026-04-29 15:18:30 +08:00
console . error ( 'Local stream is not available' ) ;
2026-05-24 01:54:47 +08:00
showNotification ( '本地视频流不可用' , 'error' ) ;
2026-04-29 15:18:30 +08:00
}
} ;
this . renderstreaming . onDisconnect = ( ) => {
console . log ( 'Received disconnect from server, host left or room closed' ) ;
2026-05-24 01:54:47 +08:00
this . hangUp ( ) ;
2026-04-29 15:18:30 +08:00
} ;
this . renderstreaming . onGotAnswer = ( connectionId ) => {
console . log ( 'SDP Answer received, resetting encoding parameters for connectionId:' , connectionId ) ;
if ( this . role === 'host' ) {
const allParticipantIds = Object . keys ( this . state . remoteStreams || { } ) ;
for ( const pid of allParticipantIds ) {
setTimeout ( ( ) => { this . setVideoEncodingParameters ( pid ) ; } , 50 ) ;
}
2026-05-24 01:54:47 +08:00
}
else {
2026-04-29 15:18:30 +08:00
setTimeout ( ( ) => { this . setVideoEncodingParameters ( ) ; } , 50 ) ;
}
} ;
this . renderstreaming . onParticipantJoined = ( participantId ) => {
console . log ( ` Participant joined: ${ participantId } ` ) ;
2026-05-24 00:54:58 +08:00
this . _upsertParticipant ( participantId ) ;
this . _notifyParticipantsUpdate ( ) ;
2026-04-29 15:18:30 +08:00
this . broadcastParticipantsList ( ) ;
} ;
this . renderstreaming . onParticipantLeft = ( participantId ) => {
console . log ( ` Participant left: ${ participantId } , room still active ` ) ;
this . updateRemoteUserStatus ( 'offline' ) ;
this . updateRemoteUserNetworkQuality ( 'no_signal' ) ;
2026-05-24 01:54:47 +08:00
showNotification ( '对方已离开通话' , 'warning' ) ;
2026-04-29 15:18:30 +08:00
if ( this . state . remoteStreams [ participantId ] ) {
this . state . remoteStreams [ participantId ] . getTracks ( ) . forEach ( track => track . stop ( ) ) ;
delete this . state . remoteStreams [ participantId ] ;
}
if ( this . state . remoteStream ) {
this . state . remoteStream . getTracks ( ) . forEach ( track => track . stop ( ) ) ;
this . state . remoteStream = null ;
}
2026-05-24 00:54:58 +08:00
this . _removeParticipant ( participantId ) ;
2026-04-29 15:18:30 +08:00
this . notify ( { type : 'PARTICIPANT_LEFT' , connectionId : participantId } ) ;
2026-05-24 00:54:58 +08:00
this . _notifyParticipantsUpdate ( ) ;
2026-04-29 15:18:30 +08:00
this . broadcastParticipantsList ( ) ;
} ;
this . renderstreaming . onTrackEvent = ( data ) => {
2026-05-24 00:54:58 +08:00
this . _handleTrackEvent ( data ) ;
2026-04-29 15:18:30 +08:00
} ;
this . renderstreaming . onMessage = ( data ) => {
2026-05-24 00:54:58 +08:00
this . _handleRenderStreamingMessage ( data ) ;
2026-04-29 15:18:30 +08:00
} ;
}
async _startConnection ( connectionId ) {
await this . renderstreaming . start ( ) ;
await this . renderstreaming . createConnection ( connectionId ) ;
this . startNetworkQualityDetection ( ) ;
this . startActivityDetection ( this . state . localStream , { isLocal : true } ) ;
this . startActivityDetection ( this . state . remoteStream , { isLocal : false } ) ;
}
async hangUp ( ) {
2026-05-24 01:54:47 +08:00
this . clearStatsMessage ( ) ;
this . stopNetworkQualityDetection ( ) ;
2026-04-29 15:18:30 +08:00
if ( this . durationInterval ) {
clearInterval ( this . durationInterval ) ;
this . durationInterval = null ;
}
this . durationSynced = false ;
const isHost = this . role === 'host' ;
console . log ( ` Disconnect peer on ${ this . connectionId } . Role: ${ this . role } ` ) ;
if ( this . renderstreaming ) {
try {
await this . renderstreaming . deleteConnection ( ) ;
await this . renderstreaming . stop ( ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error during hangUp:' , error ) ;
}
this . renderstreaming = null ;
}
this . updateRemoteUserStatus ( 'offline' ) ;
this . updateRemoteUserNetworkQuality ( 'no_signal' ) ;
this . state . participants = { } ;
this . selfParticipantId = null ;
this . connectionId = null ;
this . role = null ;
this . state . session . status = 'ended' ;
this . notify ( { type : 'CALL_ENDED' , reason : isHost ? 'host_hangup' : 'participant_hangup' } ) ;
}
2026-05-24 00:54:58 +08:00
_handleTrackEvent ( data ) {
const direction = data . transceiver . direction ;
if ( direction === 'sendrecv' || direction === 'recvonly' ) {
this . _handleIncomingTrack ( data ) ;
return ;
}
if ( direction === 'sendonly' && data . track . kind === 'audio' ) {
this . startActivityDetection ( this . state . localStream , { isLocal : true } ) ;
}
}
_handleIncomingTrack ( data ) {
const trackParticipantId = data . participantId || this . connectionId ;
const isHost = this . role === 'host' ;
const targetStream = this . _getOrCreateRemoteStream ( trackParticipantId , isHost ) ;
this . _replaceTrackOfSameKind ( targetStream , data . track ) ;
console . log ( 'Added new track:' , data . track . kind , 'for participant:' , trackParticipantId ) ;
if ( isHost && ! this . state . participants [ trackParticipantId ] ) {
this . _upsertParticipant ( trackParticipantId ) ;
this . _notifyParticipantsUpdate ( ) ;
this . broadcastParticipantsList ( ) ;
}
this . _notifyRemoteStreamUpdate ( targetStream , trackParticipantId , isHost , data . track . kind ) ;
if ( this . state . session . remoteUser . status !== 'online' ) {
this . updateRemoteUserStatus ( 'online' ) ;
this . updateRemoteUserNetworkQuality ( 'good' ) ;
this . sendMessage ( 'user-info' , {
id : this . state . session . localUser . id ,
name : this . state . session . localUser . name ,
avatar : this . state . session . localUser . avatar
} ) ;
this . _startDurationTimer ( ) ;
}
if ( data . track . kind === 'audio' ) {
this . startActivityDetection ( this . state . remoteStream , { isLocal : false } ) ;
}
}
_getOrCreateRemoteStream ( trackParticipantId , isHost ) {
if ( isHost ) {
if ( ! this . state . remoteStreams [ trackParticipantId ] ) {
this . state . remoteStreams [ trackParticipantId ] = new MediaStream ( ) ;
}
return this . state . remoteStreams [ trackParticipantId ] ;
}
if ( this . state . remoteStream == null ) {
this . state . remoteStream = new MediaStream ( ) ;
}
return this . state . remoteStream ;
}
_replaceTrackOfSameKind ( targetStream , track ) {
const existingTracks = targetStream . getTracks ( ) . filter ( existingTrack => existingTrack . kind === track . kind ) ;
existingTracks . forEach ( existingTrack => {
targetStream . removeTrack ( existingTrack ) ;
console . log ( 'Removed old track:' , existingTrack . kind ) ;
} ) ;
targetStream . addTrack ( track ) ;
}
_notifyRemoteStreamUpdate ( targetStream , trackParticipantId , isHost , trackKind ) {
const notifyStreamUpdate = ( ) => {
this . notify ( {
type : 'REMOTE_STREAM_OBTAINED' ,
stream : targetStream ,
connectionId : trackParticipantId ,
isHost
} ) ;
console . log ( 'Notified UI about remote stream update' ) ;
} ;
if ( trackKind === 'audio' && targetStream . getVideoTracks ( ) . length === 0 ) {
console . log ( 'Audio track arrived first, delaying stream notification for video track...' ) ;
setTimeout ( ( ) => {
const nowHasVideo = targetStream . getVideoTracks ( ) . length > 0 ;
console . log ( ` After delay, stream has video: ${ nowHasVideo } ` ) ;
notifyStreamUpdate ( ) ;
} , 200 ) ;
return ;
}
notifyStreamUpdate ( ) ;
}
_handleRenderStreamingMessage ( data ) {
2026-05-24 01:54:47 +08:00
console . log ( '收到信令消息:' , data ) ;
2026-05-24 00:54:58 +08:00
switch ( data . type ) {
case 'chat-message' :
this . _handleChatMessage ( data ) ;
break ;
case 'media-state-changed' :
this . _handleMediaStateChangedMessage ( data ) ;
break ;
case 'user-info' :
this . _handleUserInfoMessage ( data ) ;
break ;
case 'participants-sync' :
this . _handleParticipantsSyncMessage ( data ) ;
break ;
default :
break ;
}
}
_handleChatMessage ( data ) {
const chatPayload = data . data || data . message ;
if ( ! chatPayload ) {
return ;
}
chatMessage . handleChatMessage ( chatPayload ) ;
if ( data . participantId && this . role === 'host' && this . state . participants [ data . participantId ] ) {
this . _upsertParticipant ( data . participantId , {
id : chatPayload . senderId ,
... ( chatPayload . senderName ? { name : chatPayload . senderName } : { } ) ,
... ( chatPayload . senderAvatar ? { avatar : chatPayload . senderAvatar } : { } )
} ) ;
this . _notifyParticipantsUpdate ( ) ;
this . broadcastParticipantsList ( ) ;
return ;
}
if ( ! this . role || this . role !== 'host' ) {
if ( data . participantId && this . state . participants [ data . participantId ] ) {
this . _upsertParticipant ( data . participantId , {
... ( chatPayload . senderName ? { name : chatPayload . senderName } : { } ) ,
... ( chatPayload . senderAvatar ? { avatar : chatPayload . senderAvatar } : { } )
} ) ;
this . _notifyParticipantsUpdate ( ) ;
2026-05-24 01:54:47 +08:00
}
else if ( chatPayload . senderId !== this . state . session . localUser . id ) {
2026-05-24 00:54:58 +08:00
this . _updateRemoteUserProfile ( {
id : chatPayload . senderId ,
name : chatPayload . senderName ,
avatar : chatPayload . senderAvatar
} ) ;
}
}
}
_handleMediaStateChangedMessage ( data ) {
2026-05-24 01:54:47 +08:00
console . log ( '收到媒体状态更新:' , data . data , 'from participant:' , data . participantId ) ;
2026-05-24 00:54:58 +08:00
if ( this . role === 'host' ) {
if ( data . participantId && this . state . participants [ data . participantId ] ) {
this . _upsertParticipant ( data . participantId , {
mediaState : data . data
} ) ;
}
this . updateRemoteMedia ( data . data , data . participantId ) ;
this . _notifyParticipantsUpdate ( ) ;
this . broadcastParticipantsList ( ) ;
return ;
}
if ( data . participantId && data . participantId !== this . selfParticipantId && this . state . participants [ data . participantId ] ) {
this . _upsertParticipant ( data . participantId , {
mediaState : data . data
} ) ;
this . _notifyParticipantsUpdate ( ) ;
return ;
}
if ( data . participantId === this . selfParticipantId ) {
return ;
}
console . log ( 'Received media-state-changed from Host, updating remoteUser:' , data . data ) ;
this . updateRemoteMedia ( data . data , data . participantId ) ;
this . _notifyParticipantsUpdate ( ) ;
}
_handleUserInfoMessage ( data ) {
2026-05-24 01:54:47 +08:00
console . log ( '收到用户信息:' , data . data , 'from participant:' , data . participantId ) ;
2026-05-24 00:54:58 +08:00
if ( ! data . data ) {
return ;
}
if ( data . participantId && this . role === 'host' ) {
this . _upsertParticipant ( data . participantId , {
id : data . data . id || '' ,
name : data . data . name || DEFAULT _PARTICIPANT _NAME ,
avatar : data . data . avatar || DEFAULT _PARTICIPANT _AVATAR
} ) ;
this . _notifyParticipantsUpdate ( ) ;
this . broadcastParticipantsList ( ) ;
return ;
}
this . _updateRemoteUserProfile ( {
id : data . data . id || this . state . session . remoteUser . id ,
name : data . data . name || this . state . session . remoteUser . name ,
avatar : data . data . avatar || this . state . session . remoteUser . avatar
} ) ;
}
_handleParticipantsSyncMessage ( data ) {
if ( this . role === 'host' || ! data . data ) {
return ;
}
2026-05-24 01:54:47 +08:00
console . log ( '收到成员同步列表:' , data . data ) ;
2026-05-24 00:54:58 +08:00
this . state . participants = omitParticipant ( data . data , this . selfParticipantId ) ;
this . _notifyParticipantsUpdate ( ) ;
this . _syncCallDuration ( data . callDuration ) ;
}
_updateRemoteUserProfile ( profile ) {
2026-05-24 01:29:34 +08:00
this . _setRemoteUserState ( profile ) ;
this . _notifyRemoteUserChange ( { mediaState : this . state . session . remoteUser . mediaState } ) ;
2026-05-24 00:54:58 +08:00
}
_syncCallDuration ( callDuration ) {
if ( this . durationSynced || typeof callDuration !== 'number' ) {
return ;
}
this . state . session . duration = callDuration ;
this . durationSynced = true ;
this . _startDurationTimer ( ) ;
this . notify ( { type : 'DURATION_UPDATE' , duration : this . state . session . duration } ) ;
2026-05-24 01:46:57 +08:00
console . log ( ` Call duration synced: ${ callDuration } seconds ` ) ;
2026-05-24 00:54:58 +08:00
}
_startDurationTimer ( ) {
if ( this . durationInterval ) {
return ;
}
this . durationInterval = setInterval ( ( ) => {
this . state . session . duration ++ ;
this . notify ( { type : 'DURATION_UPDATE' , duration : this . state . session . duration } ) ;
} , 1000 ) ;
}
2026-05-24 01:29:34 +08:00
_setRemoteUserState ( patch ) {
this . state . session . remoteUser = {
... this . state . session . remoteUser ,
... patch
} ;
}
_setRemoteUserMediaState ( mediaState ) {
this . _setRemoteUserState ( {
mediaState : {
... this . state . session . remoteUser . mediaState ,
... mediaState
}
} ) ;
}
_notifyRemoteUserChange ( changes = { } ) {
this . notify ( {
type : 'REMOTE_MEDIA_CHANGE' ,
... changes ,
localUser : this . state . session . localUser ,
remoteUser : this . state . session . remoteUser
} ) ;
this . _notifyUserListUpdate ( ) ;
}
2026-04-29 15:18:30 +08:00
sendMessage ( type , data ) {
if ( this . renderstreaming ) {
this . renderstreaming . sendMessage ( {
type : type ,
data : data
} ) ;
}
}
2026-05-24 00:54:58 +08:00
_notifyParticipantsUpdate ( ) {
this . notify ( { type : 'PARTICIPANTS_UPDATE' , participants : this . state . participants } ) ;
}
_upsertParticipant ( participantId , patch = { } ) {
return upsertParticipant ( this . state . participants , participantId , patch ) ;
}
_removeParticipant ( participantId ) {
return removeParticipant ( this . state . participants , participantId ) ;
}
2026-04-29 15:18:30 +08:00
broadcastParticipantsList ( ) {
2026-05-24 01:54:47 +08:00
if ( this . role !== 'host' || ! this . renderstreaming )
return ;
2026-05-24 00:54:58 +08:00
const memberList = buildParticipantsSyncData ( this . state . session . localUser , this . state . participants ) ;
2026-04-29 15:18:30 +08:00
this . renderstreaming . sendMessage ( {
type : 'participants-sync' ,
data : memberList ,
callDuration : this . state . session . duration
} ) ;
console . log ( 'Broadcast participants list:' , Object . keys ( memberList ) ) ;
}
setCodecPreferences ( participantId ) {
const capabilities = RTCRtpSender . getCapabilities ( 'video' ) ;
2026-05-24 01:54:47 +08:00
if ( ! capabilities || ! capabilities . codecs || capabilities . codecs . length === 0 )
return ;
2026-04-29 15:18:30 +08:00
const { codecs } = capabilities ;
let selectedCodecs = [ ] ;
const av1Codec = codecs . find ( c => c . mimeType === 'video/AV1' ) ;
const vp9Codec = codecs . find ( c => c . mimeType === 'video/VP9' ) ;
2026-05-24 01:54:47 +08:00
const h264HighCodec = codecs . find ( c => c . mimeType === 'video/H264' &&
c . sdpFmtpLine && c . sdpFmtpLine . includes ( 'profile-level-id=6400' ) ) ;
2026-04-29 15:18:30 +08:00
const h264Codec = codecs . find ( c => c . mimeType === 'video/H264' ) ;
2026-05-24 01:54:47 +08:00
if ( av1Codec )
selectedCodecs . push ( av1Codec ) ;
if ( vp9Codec )
selectedCodecs . push ( vp9Codec ) ;
if ( h264HighCodec )
selectedCodecs . push ( h264HighCodec ) ;
if ( h264Codec && ( ! h264HighCodec || h264Codec !== h264HighCodec ) )
selectedCodecs . push ( h264Codec ) ;
if ( selectedCodecs . length === 0 )
return ;
2026-04-29 15:18:30 +08:00
if ( this . renderstreaming ) {
const transceivers = this . renderstreaming . getTransceivers ( participantId ) ;
if ( transceivers && transceivers . length > 0 ) {
const videoTransceivers = transceivers . filter ( t => {
if ( t . sender && t . sender . track ) {
return t . sender . track . kind === 'video' ;
}
return t . mid !== null && t . receiver && t . receiver . track && t . receiver . track . kind === 'video' ;
} ) ;
if ( videoTransceivers && videoTransceivers . length > 0 ) {
videoTransceivers . forEach ( t => {
try {
t . setCodecPreferences ( selectedCodecs ) ;
2026-05-24 01:54:47 +08:00
}
catch ( e ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error setting codec preferences:' , e ) ;
}
} ) ;
console . log ( ` Codec preferences set: ${ selectedCodecs . map ( c => c . mimeType ) . join ( ' > ' ) } ` ) ;
}
}
}
}
setVideoEncodingParameters ( participantId ) {
2026-05-24 01:54:47 +08:00
if ( ! this . renderstreaming )
return ;
2026-04-29 15:18:30 +08:00
const transceivers = this . renderstreaming . getTransceivers ( participantId ) ;
2026-05-24 01:54:47 +08:00
if ( ! transceivers || transceivers . length === 0 )
return ;
const videoTransceivers = transceivers . filter ( t => t . sender && t . sender . track && t . sender . track . kind === 'video' ) ;
2026-04-29 15:18:30 +08:00
for ( const transceiver of videoTransceivers ) {
try {
const sender = transceiver . sender ;
const params = sender . getParameters ( ) ;
if ( ! params . encodings || params . encodings . length === 0 ) {
params . encodings = [ { } ] ;
}
const videoTrack = sender . track ;
const settings = videoTrack ? videoTrack . getSettings ( ) : { } ;
const height = settings . height || 1080 ;
2026-05-24 01:01:28 +08:00
const maxBitrate = getAdaptiveVideoBitrate ( height ) ;
2026-04-29 15:18:30 +08:00
params . encodings [ 0 ] . maxBitrate = maxBitrate ;
params . encodings [ 0 ] . scaleResolutionDownBy = 1.0 ;
params . encodings [ 0 ] . xGoogleMinBitrate = Math . floor ( maxBitrate * 0.5 ) ;
if ( params . degradationPreference !== undefined ) {
params . degradationPreference = 'maintain-resolution' ;
}
sender . setParameters ( params ) ;
console . log ( ` Set video encoding: maxBitrate= ${ maxBitrate / 1000000 } Mbps, scaleResolutionDownBy=1.0, xGoogleMinBitrate= ${ Math . floor ( maxBitrate * 0.5 ) } ${ participantId ? ` for ${ participantId } ` : '' } ` ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error setting video encoding parameters:' , error ) ;
}
}
}
async changeResolution ( width , height ) {
if ( ! this . state . localStream ) {
2026-05-24 01:54:47 +08:00
showNotification ( '本地视频流不可用' , 'error' ) ;
2026-04-29 15:18:30 +08:00
return ;
}
const videoTracks = this . state . localStream . getVideoTracks ( ) ;
if ( videoTracks . length === 0 ) {
2026-05-24 01:54:47 +08:00
showNotification ( '视频轨道不可用' , 'error' ) ;
2026-04-29 15:18:30 +08:00
return ;
}
const track = videoTracks [ 0 ] ;
2026-05-24 01:01:28 +08:00
const label = getResolutionLabel ( height ) ;
2026-04-29 15:18:30 +08:00
try {
await track . applyConstraints ( {
width : { ideal : width , max : width } ,
height : { ideal : height , max : height } ,
frameRate : { ideal : 30 , max : 30 }
} ) ;
2026-05-24 01:54:47 +08:00
console . log ( ` 分辨率已切换为 ${ width } x ${ height } ` ) ;
2026-05-24 01:01:28 +08:00
const maxBitrate = getTargetResolutionBitrate ( height ) ;
2026-04-29 15:18:30 +08:00
this . _applyMaxBitrate ( maxBitrate ) ;
const userSettings = JSON . parse ( localStorage . getItem ( 'userSettings' ) || '{}' ) ;
userSettings . resolution = { width , height } ;
localStorage . setItem ( 'userSettings' , JSON . stringify ( userSettings ) ) ;
this . notify ( { type : 'RESOLUTION_CHANGED' , resolution : { width , height , label } } ) ;
2026-05-24 01:54:47 +08:00
showNotification ( '已切换为 ' + label , 'success' ) ;
}
catch ( error ) {
console . error ( '切换分辨率失败:' , error ) ;
showNotification ( '切换分辨率失败,摄像头可能不支持该分辨率' , 'error' ) ;
2026-04-29 15:18:30 +08:00
}
}
_applyMaxBitrate ( maxBitrate ) {
2026-05-24 01:54:47 +08:00
if ( ! this . renderstreaming )
return ;
2026-04-29 15:18:30 +08:00
const isHost = this . role === 'host' ;
const participantIds = isHost ? Object . keys ( this . state . participants ) : [ null ] ;
for ( const pid of participantIds ) {
const transceivers = this . renderstreaming . getTransceivers ( pid ) ;
2026-05-24 01:54:47 +08:00
if ( ! transceivers )
continue ;
const videoTransceivers = transceivers . filter ( t => t . sender && t . sender . track && t . sender . track . kind === 'video' ) ;
2026-04-29 15:18:30 +08:00
for ( const transceiver of videoTransceivers ) {
try {
const sender = transceiver . sender ;
const params = sender . getParameters ( ) ;
if ( ! params . encodings || params . encodings . length === 0 ) {
params . encodings = [ { } ] ;
}
params . encodings [ 0 ] . maxBitrate = maxBitrate ;
sender . setParameters ( params ) ;
console . log ( ` Updated maxBitrate to ${ maxBitrate } for ${ pid || 'self' } ` ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error updating maxBitrate:' , error ) ;
}
}
}
}
updateRemoteMedia ( mediaState , participantId ) {
2026-05-24 01:29:34 +08:00
this . _setRemoteUserMediaState ( mediaState ) ;
this . _notifyRemoteUserChange ( { mediaState , participantId } ) ;
2026-04-29 15:18:30 +08:00
}
updateRemoteUserStatus ( status ) {
2026-05-24 01:29:34 +08:00
this . _setRemoteUserState ( { status } ) ;
this . _notifyRemoteUserChange ( ) ;
2026-04-29 15:18:30 +08:00
}
updateRemoteUserNetworkQuality ( networkQuality ) {
2026-05-24 01:29:34 +08:00
this . _setRemoteUserState ( { networkQuality } ) ;
this . _notifyRemoteUserChange ( ) ;
}
_setSpeakingState ( isLocal , isSpeaking ) {
if ( isLocal ) {
this . state . session . localUser . mediaState . isSpeaking = isSpeaking ;
this . _notifyLocalMediaChange ( 'isSpeaking' , isSpeaking ) ;
this . emitMediaStateChange ( ) ;
return ;
}
this . updateRemoteMedia ( { isSpeaking } ) ;
2026-04-29 15:18:30 +08:00
}
async endCall ( ) {
console . log ( ` endCall called. Role: ${ this . role } ` ) ;
await this . hangUp ( ) ;
}
async joinCall ( connectionId ) {
this . state . session . status = 'connecting' ;
this . notify ( { type : 'CALL_STATUS_CHANGE' , status : 'connecting' } ) ;
2026-05-24 01:54:47 +08:00
showNotification ( '正在加入通话 (' + connectionId + ')' ) ;
2026-04-29 15:18:30 +08:00
await this . init ( ) ;
this . connectionId = connectionId ;
}
async createCall ( ) {
this . state . session . status = 'connecting' ;
this . notify ( { type : 'CALL_STATUS_CHANGE' , status : 'connecting' } ) ;
2026-05-24 01:54:47 +08:00
showNotification ( '正在创建通话...' ) ;
2026-04-29 15:18:30 +08:00
await this . init ( ) ;
}
async detectNetworkQuality ( ) {
if ( ! this . renderstreaming ) {
return ;
}
try {
const stats = await this . renderstreaming . getStats ( ) ;
if ( ! stats ) {
return ;
}
2026-05-24 01:01:28 +08:00
const summary = summarizeInboundStats ( stats ) ;
const quality = getNetworkQualityFromSummary ( summary ) ;
2026-04-29 15:18:30 +08:00
if ( this . state . session . remoteUser . networkQuality !== quality ) {
2026-05-24 01:29:34 +08:00
this . updateRemoteUserNetworkQuality ( quality ) ;
2026-04-29 15:18:30 +08:00
this . notify ( { type : 'NETWORK_CHANGE' , quality } ) ;
}
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error detecting network quality:' , error ) ;
}
}
startActivityDetection ( stream , { isLocal = false } = { } ) {
if ( ! stream ) {
return ;
}
const audioTracks = stream . getAudioTracks ( ) ;
if ( audioTracks . length === 0 ) {
return ;
}
try {
const { threshold , debounceTime , fftSize } = VAD _CONFIG ;
2026-05-24 01:29:34 +08:00
const { analyser , dataArray } = createAudioAnalyser ( stream , fftSize ) ;
2026-04-29 15:18:30 +08:00
let isSpeaking = false ;
let lastActivityTime = 0 ;
const detectActivity = ( ) => {
if ( ! stream || ! this . renderstreaming ) {
return ;
}
2026-05-24 01:29:34 +08:00
const level = getAudioLevel ( analyser , dataArray ) ;
2026-04-29 15:18:30 +08:00
const currentTime = Date . now ( ) ;
if ( level > threshold / 100 ) {
lastActivityTime = currentTime ;
if ( ! isSpeaking ) {
isSpeaking = true ;
2026-05-24 01:29:34 +08:00
this . _setSpeakingState ( isLocal , true ) ;
2026-04-29 15:18:30 +08:00
}
2026-05-24 01:54:47 +08:00
}
else if ( isSpeaking && currentTime - lastActivityTime > debounceTime ) {
2026-04-29 15:18:30 +08:00
isSpeaking = false ;
2026-05-24 01:29:34 +08:00
this . _setSpeakingState ( isLocal , false ) ;
2026-04-29 15:18:30 +08:00
}
if ( this . state . session . status === 'ongoing' ) {
requestAnimationFrame ( detectActivity ) ;
}
} ;
detectActivity ( ) ;
console . log ( ` ${ isLocal ? 'Local' : 'Remote' } activity detection started ` ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( ` Error starting ${ isLocal ? 'local' : 'remote' } activity detection: ` , error ) ;
}
}
startNetworkQualityDetection ( ) {
this . networkQualityInterval = setInterval ( ( ) => {
this . detectNetworkQuality ( ) ;
} , 3000 ) ;
}
stopNetworkQualityDetection ( ) {
if ( this . networkQualityInterval ) {
clearInterval ( this . networkQualityInterval ) ;
this . networkQualityInterval = null ;
}
}
emitMediaStateChange ( ) {
const payload = {
userId : this . state . session . localUser . id ,
... this . state . session . localUser . mediaState
} ;
console . log ( '[WebSocket Emit] media-state-changed:' , payload ) ;
if ( this . renderstreaming ) {
this . renderstreaming . sendMessage ( {
type : 'media-state-changed' ,
data : payload
} ) ;
}
}
async showStatsMessage ( ) {
console . log ( 'Showing stats message' ) ;
await this . detectNetworkQuality ( ) ;
this . statsInterval = setInterval ( async ( ) => {
if ( ! this . renderstreaming ) {
return ;
}
try {
const stats = await this . renderstreaming . getStats ( ) ;
if ( ! stats ) {
return ;
}
2026-05-24 01:01:28 +08:00
const statsSummary = summarizeInboundStats ( stats ) ;
2026-05-24 01:29:34 +08:00
const statsLog = buildStatsLogPayload ( this . state . session . remoteUser . networkQuality , statsSummary ) ;
2026-04-29 15:18:30 +08:00
console . log ( '=== WebRTC Statistics ===' ) ;
2026-05-24 01:29:34 +08:00
console . log ( ` Network Quality: ${ statsLog . networkQuality } ` ) ;
console . log ( 'Video Stats:' , statsLog . video ) ;
console . log ( 'Audio Stats:' , statsLog . audio ) ;
2026-04-29 15:18:30 +08:00
console . log ( '========================' ) ;
2026-05-24 01:54:47 +08:00
}
catch ( error ) {
2026-04-29 15:18:30 +08:00
console . error ( 'Error showing stats message:' , error ) ;
}
2026-05-24 01:54:47 +08:00
} , 5000 ) ;
2026-04-29 15:18:30 +08:00
}
clearStatsMessage ( ) {
console . log ( 'Clearing stats message' ) ;
if ( this . statsInterval ) {
clearInterval ( this . statsInterval ) ;
this . statsInterval = null ;
}
}
getState ( ) { return this . state ; }
getLocalUser ( ) { return this . state . session . localUser ; }
getRemoteUser ( ) { return this . state . session . remoteUser ; }
getConnectionId ( ) { return this . connectionId ; }
getRenderStreaming ( ) { return this . renderstreaming ; }
}
const store = new CallStateManager ( ) ;
window . addEventListener ( 'beforeunload' , async ( ) => {
if ( ! store . renderstreaming )
return ;
2026-05-24 01:54:47 +08:00
await store . renderstreaming . stop ( ) ;
2026-04-29 15:18:30 +08:00
} , true ) ;
export default store ;