mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(voice): one-way audio and stream loss in p2p calls (#3405)
This commit is contained in:
1 parent
d59ddc2214
commit
bcb30eea88
5 files changed
+73
-23
No files matched your search
@@ -468,9 +468,11 @@ export function useVoiceCallTracksAndLayout({channel, expandedUserIds}: UseVoice
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pinnedParticipantIdentity) return;
|
if (!pinnedParticipantIdentity) return;
|
||||||
if (cameraTracksAll.length === 0) return;
|
if (cameraTracksAll.length === 0) return;
|
||||||
const stillExists = cameraTracksAll.some((tr) => tr.participant.identity === pinnedParticipantIdentity);
|
if (cameraTracksAll.some((tr) => tr.participant.identity === pinnedParticipantIdentity)) return;
|
||||||
if (!stillExists) VoiceCallLayoutCommands.setPinnedParticipant(null);
|
const {connectionId} = parseVoiceParticipantIdentity(pinnedParticipantIdentity);
|
||||||
}, [cameraTracksAll, pinnedParticipantIdentity]);
|
if (connectionVoiceStates[connectionId]?.channel_id === channel.id) return;
|
||||||
|
VoiceCallLayoutCommands.setPinnedParticipant(null);
|
||||||
|
}, [cameraTracksAll, channel.id, connectionVoiceStates, pinnedParticipantIdentity]);
|
||||||
const {
|
const {
|
||||||
mainTrack: focusMainTrack,
|
mainTrack: focusMainTrack,
|
||||||
carouselTracks,
|
carouselTracks,
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ class MediaEngineFacade extends Store {
|
|||||||
restoreStream: boolean;
|
restoreStream: boolean;
|
||||||
screenShareSnapshot: ScreenShareReconnectSnapshot | null;
|
screenShareSnapshot: ScreenShareReconnectSnapshot | null;
|
||||||
toastWhenStreamNotRestored: boolean;
|
toastWhenStreamNotRestored: boolean;
|
||||||
|
sendUpdateWhenStreamNotRestored: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
assert.equal(typeof args.restoreCamera, 'boolean', 'restoreLocalMedia requires a camera flag');
|
assert.equal(typeof args.restoreCamera, 'boolean', 'restoreLocalMedia requires a camera flag');
|
||||||
assert.equal(typeof args.restoreStream, 'boolean', 'restoreLocalMedia requires a stream flag');
|
assert.equal(typeof args.restoreStream, 'boolean', 'restoreLocalMedia requires a stream flag');
|
||||||
@@ -828,7 +829,9 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
if (restored) return;
|
if (restored) return;
|
||||||
if (streamRestoreFailed || args.screenShareSnapshot !== null) {
|
if (streamRestoreFailed || args.screenShareSnapshot !== null) {
|
||||||
voiceEngineV2AppMediaStateAdapter.applyScreenShareState(false, {sendUpdate: false});
|
voiceEngineV2AppMediaStateAdapter.applyScreenShareState(false, {
|
||||||
|
sendUpdate: args.sendUpdateWhenStreamNotRestored,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (args.toastWhenStreamNotRestored && this.i18n) {
|
if (args.toastWhenStreamNotRestored && this.i18n) {
|
||||||
ToastCommands.createToast({
|
ToastCommands.createToast({
|
||||||
@@ -1690,7 +1693,7 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.reconcileLocalAudioStateInBackground('region hot-swap complete');
|
this.reconcileLocalAudioStateInBackground('region hot-swap complete');
|
||||||
if (isConversion) void this.reapplyVideoQualityLimits(newRoom);
|
if (isConversion) void this.finishMeshConversion(currentRoom, newRoom);
|
||||||
},
|
},
|
||||||
(_guildId, _channelId, _connectionId, _attemptId, error) => {
|
(_guildId, _channelId, _connectionId, _attemptId, error) => {
|
||||||
logger.warn('LiveKit connect failed, clearing gateway voice state', {error});
|
logger.warn('LiveKit connect failed, clearing gateway voice state', {error});
|
||||||
@@ -2715,6 +2718,19 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async finishMeshConversion(meshRoom: Room, room: Room): Promise<void> {
|
||||||
|
await this.restoreLocalMedia({
|
||||||
|
reason: 'mesh conversion',
|
||||||
|
room,
|
||||||
|
restoreCamera: false,
|
||||||
|
restoreStream: false,
|
||||||
|
screenShareSnapshot: voiceEngineV2AppScreenShareExecutionAdapter.prepareScreenShareReconnect(meshRoom),
|
||||||
|
toastWhenStreamNotRestored: true,
|
||||||
|
sendUpdateWhenStreamNotRestored: true,
|
||||||
|
});
|
||||||
|
await this.reapplyVideoQualityLimits(room);
|
||||||
|
}
|
||||||
|
|
||||||
private async restoreLocalMediaState(roomOverride?: Room | null): Promise<void> {
|
private async restoreLocalMediaState(roomOverride?: Room | null): Promise<void> {
|
||||||
const room = roomOverride ?? voiceEngineV2AppConnectionHostAdapter.room;
|
const room = roomOverride ?? voiceEngineV2AppConnectionHostAdapter.room;
|
||||||
const participant = room?.localParticipant ?? null;
|
const participant = room?.localParticipant ?? null;
|
||||||
@@ -2728,6 +2744,7 @@ class MediaEngineFacade extends Store {
|
|||||||
restoreStream: LocalVoiceState.getSelfStream() && !participant.isScreenShareEnabled,
|
restoreStream: LocalVoiceState.getSelfStream() && !participant.isScreenShareEnabled,
|
||||||
screenShareSnapshot: pendingScreenShareReconnect,
|
screenShareSnapshot: pendingScreenShareReconnect,
|
||||||
toastWhenStreamNotRestored: false,
|
toastWhenStreamNotRestored: false,
|
||||||
|
sendUpdateWhenStreamNotRestored: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2744,6 +2761,7 @@ class MediaEngineFacade extends Store {
|
|||||||
restoreStream: pendingRestore.restoreStream,
|
restoreStream: pendingRestore.restoreStream,
|
||||||
screenShareSnapshot: null,
|
screenShareSnapshot: null,
|
||||||
toastWhenStreamNotRestored: true,
|
toastWhenStreamNotRestored: true,
|
||||||
|
sendUpdateWhenStreamNotRestored: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,20 @@ export function bindRoomEvents(
|
|||||||
participantSpeakingDisposers.get(identity)?.dispose();
|
participantSpeakingDisposers.get(identity)?.dispose();
|
||||||
participantSpeakingDisposers.delete(identity);
|
participantSpeakingDisposers.delete(identity);
|
||||||
};
|
};
|
||||||
|
const observeRemoteScreenSharePublication = (participant: Participant, pub: RemoteTrackPublication): void => {
|
||||||
|
reportScreenSharePublicationObserved(participant.identity, pub.trackSid ?? null);
|
||||||
|
const streamKey = getStreamKeyForParticipantIdentity(guildId, channelId, participant.identity);
|
||||||
|
if (streamKey) {
|
||||||
|
cancelDeferredStopWatchingStreamKey(streamKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observeExistingRemoteScreenSharePublications = (): void => {
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
for (const pub of participant.trackPublications.values()) {
|
||||||
|
if (pub.source === Track.Source.ScreenShare) observeRemoteScreenSharePublication(participant, pub);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
const endScreenShareIfPublicationDidNotReturn = (): void => {
|
const endScreenShareIfPublicationDidNotReturn = (): void => {
|
||||||
const publication = screenSharePublicationAwaitingReconnect;
|
const publication = screenSharePublicationAwaitingReconnect;
|
||||||
screenSharePublicationAwaitingReconnect = null;
|
screenSharePublicationAwaitingReconnect = null;
|
||||||
@@ -305,6 +319,7 @@ export function bindRoomEvents(
|
|||||||
bindParticipantSpeakingEvents(room.localParticipant);
|
bindParticipantSpeakingEvents(room.localParticipant);
|
||||||
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
|
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
|
||||||
dependencies.remoteSpeaking.hydrateFromRoom(room);
|
dependencies.remoteSpeaking.hydrateFromRoom(room);
|
||||||
|
observeExistingRemoteScreenSharePublications();
|
||||||
bindScreenShareNegotiation();
|
bindScreenShareNegotiation();
|
||||||
dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf);
|
dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf);
|
||||||
dependencies.connection.markConnected();
|
dependencies.connection.markConnected();
|
||||||
@@ -596,11 +611,7 @@ export function bindRoomEvents(
|
|||||||
pub.setSubscribed(true);
|
pub.setSubscribed(true);
|
||||||
}
|
}
|
||||||
if (pub.source === Track.Source.ScreenShare && !participant.isLocal) {
|
if (pub.source === Track.Source.ScreenShare && !participant.isLocal) {
|
||||||
reportScreenSharePublicationObserved(participant.identity, pub.trackSid ?? null);
|
observeRemoteScreenSharePublication(participant, pub);
|
||||||
const streamKey = getStreamKeyForParticipantIdentity(guildId, channelId, participant.identity);
|
|
||||||
if (streamKey) {
|
|
||||||
cancelDeferredStopWatchingStreamKey(streamKey);
|
|
||||||
}
|
|
||||||
dependencies.subscriptions.reattachScreenShareAfterPublish(participant.identity, pub);
|
dependencies.subscriptions.reattachScreenShareAfterPublish(participant.identity, pub);
|
||||||
dependencies.subscriptions.reconcileSubscriptions();
|
dependencies.subscriptions.reconcileSubscriptions();
|
||||||
}
|
}
|
||||||
@@ -615,6 +626,7 @@ export function bindRoomEvents(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (room.state === ConnectionState.Connected) {
|
if (room.state === ConnectionState.Connected) {
|
||||||
|
guard(attemptId, observeExistingRemoteScreenSharePublications)();
|
||||||
guard(attemptId, bindScreenShareNegotiation)();
|
guard(attemptId, bindScreenShareNegotiation)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +320,7 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
handlePeerStatus(peer: VoiceMeshPeer): void {
|
handlePeerStatus(peer: VoiceMeshPeer): void {
|
||||||
if (this.peers.get(peer.connectionId) !== peer) return;
|
if (this.peers.get(peer.connectionId) !== peer) return;
|
||||||
const {status} = peer;
|
const {status} = peer;
|
||||||
|
if (status === 'failed' && this.ownP2p() === false) return;
|
||||||
VoiceMeshPeers.setStatus(peer.connectionId, status);
|
VoiceMeshPeers.setStatus(peer.connectionId, status);
|
||||||
if (status === 'connecting') return;
|
if (status === 'connecting') return;
|
||||||
this.reportPeer(peer, status);
|
this.reportPeer(peer, status);
|
||||||
@@ -395,28 +396,30 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
const serverMuted =
|
const serverMuted =
|
||||||
src === 'microphone' &&
|
src === 'microphone' &&
|
||||||
voiceEngineV2AppVoiceStateAdapter.getVoiceStateByConnectionId(peer.connectionId)?.mute === true;
|
voiceEngineV2AppVoiceStateAdapter.getVoiceStateByConnectionId(peer.connectionId)?.mute === true;
|
||||||
if (receiver && receiver.track.readyState === 'live' && publication.isDesired && !serverMuted) {
|
const live = receiver !== null && receiver.track.readyState === 'live' && publication.isDesired && !serverMuted;
|
||||||
if (publication.track?.mediaStreamTrack !== receiver.track) {
|
if (live && publication.track?.mediaStreamTrack === receiver.track) return;
|
||||||
participant.addSubscribedMediaTrack(receiver.track, info.sid, new MediaStream([receiver.track]), receiver);
|
if (publication.track) {
|
||||||
}
|
publication.track.stop();
|
||||||
return;
|
publication.setTrack(undefined);
|
||||||
|
}
|
||||||
|
if (live) {
|
||||||
|
participant.addSubscribedMediaTrack(receiver.track, info.sid, new MediaStream([receiver.track]), receiver);
|
||||||
}
|
}
|
||||||
if (!publication.track) return;
|
|
||||||
publication.track.stop();
|
|
||||||
publication.setTrack(undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly syncRoster = (): void => {
|
private readonly syncRoster = (): void => {
|
||||||
if (!this.room) return;
|
if (!this.room) return;
|
||||||
const {connectionId, channelId, guildId} = this.target;
|
const {connectionId, channelId, guildId} = this.target;
|
||||||
const own = voiceEngineV2AppVoiceStateAdapter.getVoiceStateByConnectionId(connectionId);
|
const ownP2p = this.ownP2p();
|
||||||
const ownP2p = own?.channel_id === channelId ? own.p2p : undefined;
|
|
||||||
this.syncConversionWatchdog(ownP2p === false && !this.conversionHeld);
|
this.syncConversionWatchdog(ownP2p === false && !this.conversionHeld);
|
||||||
const roster = new Map<string, NormalizedVoiceState>();
|
const roster = new Map<string, NormalizedVoiceState>();
|
||||||
if (ownP2p === true) {
|
if (ownP2p !== undefined) {
|
||||||
const states = voiceEngineV2AppVoiceStateAdapter.getAllVoiceStatesInChannel(guildId ?? ME, channelId);
|
const states = voiceEngineV2AppVoiceStateAdapter.getAllVoiceStatesInChannel(guildId ?? ME, channelId);
|
||||||
for (const state of Object.values(states)) {
|
for (const state of Object.values(states)) {
|
||||||
if (state.p2p === true && state.connection_id !== connectionId) roster.set(state.connection_id, state);
|
if (state.connection_id === connectionId) continue;
|
||||||
|
if (this.peers.has(state.connection_id) || (ownP2p && state.p2p === true)) {
|
||||||
|
roster.set(state.connection_id, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const before = this.peers.size;
|
const before = this.peers.size;
|
||||||
@@ -431,6 +434,12 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
for (const peer of this.peers.values()) this.syncPeerReceiver(peer, 'microphone');
|
for (const peer of this.peers.values()) this.syncPeerReceiver(peer, 'microphone');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private ownP2p(): boolean | undefined {
|
||||||
|
const {connectionId, channelId} = this.target;
|
||||||
|
const own = voiceEngineV2AppVoiceStateAdapter.getVoiceStateByConnectionId(connectionId);
|
||||||
|
return own?.channel_id === channelId ? own.p2p : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private addPeer(state: NormalizedVoiceState): void {
|
private addPeer(state: NormalizedVoiceState): void {
|
||||||
const peer = new VoiceMeshPeer(this, state.connection_id, state.user_id, this.target.connectionId);
|
const peer = new VoiceMeshPeer(this, state.connection_id, state.user_id, this.target.connectionId);
|
||||||
this.peers.set(peer.connectionId, peer);
|
this.peers.set(peer.connectionId, peer);
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import Authentication from '@app/features/auth/state/Authentication';
|
import Authentication from '@app/features/auth/state/Authentication';
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
import {createVoiceMeshRoom, holdVoiceMeshConversion} from '@app/features/voice/engine/mesh/VoiceMeshRoom';
|
import {
|
||||||
|
createVoiceMeshRoom,
|
||||||
|
holdVoiceMeshConversion,
|
||||||
|
isVoiceMeshRoom,
|
||||||
|
} from '@app/features/voice/engine/mesh/VoiceMeshRoom';
|
||||||
import {Store} from '@app/features/voice/engine/Store';
|
import {Store} from '@app/features/voice/engine/Store';
|
||||||
import {sendVoiceStateDisconnect} from '@app/features/voice/engine/VoiceChannelConnector';
|
import {sendVoiceStateDisconnect} from '@app/features/voice/engine/VoiceChannelConnector';
|
||||||
import {
|
import {
|
||||||
@@ -785,8 +789,13 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store {
|
|||||||
logger.warn('Region hot-swap: missing participant for track republishing');
|
logger.warn('Region hot-swap: missing participant for track republishing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isMeshConversion = isVoiceMeshRoom(oldRoom);
|
||||||
const publications = selectLocalMediaPublicationsForConnectionRepublish(
|
const publications = selectLocalMediaPublicationsForConnectionRepublish(
|
||||||
Array.from(oldParticipant.trackPublications.values()),
|
Array.from(oldParticipant.trackPublications.values()),
|
||||||
|
).filter(
|
||||||
|
(publication) =>
|
||||||
|
!isMeshConversion ||
|
||||||
|
(publication.source !== Track.Source.ScreenShare && publication.source !== Track.Source.ScreenShareAudio),
|
||||||
);
|
);
|
||||||
const errors: Array<{source: string; error: unknown}> = [];
|
const errors: Array<{source: string; error: unknown}> = [];
|
||||||
let codecPolicyFailure: Error | null = null;
|
let codecPolicyFailure: Error | null = null;
|
||||||
|
|||||||
Reference in new issue
Block a user