diff --git a/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.test.ts b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.test.ts new file mode 100644 index 000000000..8ca544404 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap'; +import type {RoomEventCallbacks, RoomEventDependencies} from '@app/features/voice/engine/VoiceRoomEventBinder'; +import {ConnectionState, type Room, RoomEvent} from 'livekit-client'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const mocks = vi.hoisted(() => ({ + bindCodecNegotiation: vi.fn(() => vi.fn()), + disposeCodecNegotiation: vi.fn(), + bindScreenShareMigration: vi.fn(() => vi.fn()), + disposeScreenShareMigration: vi.fn(), +})); + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); + +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}), +})); + +vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({ + default: { + bind: mocks.bindCodecNegotiation, + dispose: mocks.disposeCodecNegotiation, + getSelectedCodec: vi.fn(() => null), + publishLocalCapabilities: vi.fn(async () => null), + selectNativeScreenShareCodec: vi.fn(() => null), + selectScreenShareCodec: vi.fn(() => null), + setSelectionChangeListener: vi.fn(), + }, +})); + +vi.mock('@app/features/voice/engine/ScreenSharePublicationMigration', () => ({ + default: { + bind: mocks.bindScreenShareMigration, + dispose: mocks.disposeScreenShareMigration, + getManagedScreenSharePublications: vi.fn(() => []), + getScreenSharePublicationsToDisable: vi.fn(() => []), + isScreenShareBuffering: vi.fn(() => false), + selectScreenSharePublication: vi.fn(() => null), + subscribe: vi.fn(() => vi.fn()), + version: 0, + }, +})); + +installVoiceMenuTestBootstrap(); + +const {bindRoomEvents} = await import('@app/features/voice/engine/VoiceRoomEventBinder'); + +class FakeRoom { + private handlers = new Map) => unknown>>(); + localParticipant = {identity: 'local', on: vi.fn(), off: vi.fn()}; + remoteParticipants = new Map(); + + constructor(public state: ConnectionState) {} + + on(event: string, handler: (...args: Array) => unknown): this { + if (!this.handlers.has(event)) this.handlers.set(event, new Set()); + this.handlers.get(event)?.add(handler); + return this; + } + + off(event: string, handler: (...args: Array) => unknown): this { + this.handlers.get(event)?.delete(handler); + return this; + } + + async emit(event: string, ...args: Array): Promise { + for (const handler of this.handlers.get(event) ?? []) { + await handler(...args); + } + } +} + +function createCallbacks(): RoomEventCallbacks { + return { + onConnected: vi.fn(async () => undefined), + onDisconnected: vi.fn(), + onReconnecting: vi.fn(), + onReconnected: vi.fn(), + }; +} + +function createDependencies(options: {attemptIsStale?: boolean} = {}): RoomEventDependencies { + return { + connection: { + createGuardedHandler: (_attemptId, handler) => { + return (...args) => { + if (options.attemptIsStale) return; + void handler(...args); + }; + }, + isDisconnecting: () => false, + isUserMovePending: () => true, + markConnected: vi.fn(), + markDisconnected: vi.fn(), + markReconnecting: vi.fn(), + markReconnected: vi.fn(), + }, + media: { + applyAllLocalAudioPreferences: vi.fn(), + ensureMicrophone: vi.fn(async () => undefined), + playEntranceSound: vi.fn(async () => undefined), + resetStreamTracking: vi.fn(), + }, + mediaState: { + handleLocalTrackStateChange: vi.fn(() => false), + resetLocalMediaState: vi.fn(), + }, + participants: { + clear: vi.fn(), + hydrateFromRoom: vi.fn(), + removeParticipant: vi.fn(), + updateActiveSpeakers: vi.fn(), + upsertParticipant: vi.fn(), + }, + permissions: { + applyDeafen: vi.fn(), + syncWithPermissionState: vi.fn(), + }, + remoteSpeaking: { + attachIfApplicable: vi.fn(), + clear: vi.fn(), + detachByIdentity: vi.fn(), + detachIfTrackMatches: vi.fn(), + hydrateFromRoom: vi.fn(), + }, + screenShare: { + cleanupLingeringScreenShareTracks: vi.fn(async () => undefined), + handleLocalScreenShareTrackUnpublished: vi.fn(), + isScreenSharePublicationReplaceInFlight: () => false, + }, + subscriptions: { + isScreenShareSubscribed: () => false, + reattachScreenShareAfterPublish: vi.fn(), + reconcileSubscriptions: vi.fn(), + }, + }; +} + +function bindFakeRoom(room: FakeRoom, options: {attemptIsStale?: boolean} = {}): void { + bindRoomEvents(room as unknown as Room, 1, 'guild-1', 'channel-1', createCallbacks(), createDependencies(options)); +} + +describe('binding room events for screen share negotiation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('binds codec negotiation and publication migration to a room that is already connected', () => { + bindFakeRoom(new FakeRoom(ConnectionState.Connected)); + expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1); + expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1); + }); + + it('binds neither until a room that is not connected yet reports it has connected', async () => { + const room = new FakeRoom(ConnectionState.Disconnected); + bindFakeRoom(room); + expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled(); + expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled(); + room.state = ConnectionState.Connected; + await room.emit(RoomEvent.Connected); + expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1); + expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1); + }); + + it('binds once however often an already connected room reports it has connected', async () => { + const room = new FakeRoom(ConnectionState.Connected); + bindFakeRoom(room); + await room.emit(RoomEvent.Connected); + await room.emit(RoomEvent.Connected); + expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1); + expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1); + }); + + it('binds again after an already connected room disconnects and connects once more', async () => { + const room = new FakeRoom(ConnectionState.Connected); + bindFakeRoom(room); + await room.emit(RoomEvent.Disconnected); + expect(mocks.disposeCodecNegotiation).toHaveBeenCalledTimes(1); + expect(mocks.disposeScreenShareMigration).toHaveBeenCalledTimes(1); + await room.emit(RoomEvent.Connected); + expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(2); + expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(2); + }); + + it('binds nothing to an already connected room whose connection attempt is stale', () => { + bindFakeRoom(new FakeRoom(ConnectionState.Connected), {attemptIsStale: true}); + expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled(); + expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts index b527ccedb..391826987 100644 --- a/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts +++ b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts @@ -29,7 +29,7 @@ import type { RemoteTrackPublication, Room, } from 'livekit-client'; -import {ParticipantEvent, RoomEvent, Track} from 'livekit-client'; +import {ConnectionState, ParticipantEvent, RoomEvent, Track} from 'livekit-client'; const logger = new Logger('VoiceRoomEventBinder'); @@ -145,6 +145,7 @@ export function bindRoomEvents( const remoteTrackLifecycleDisposers = new Map void>(); let codecNegotiationDisposer: (() => void) | null = null; let screenShareMigrationDisposer: (() => void) | null = null; + let screenShareNegotiationBound = false; const remoteTrackLifecycleRoleFor = (pub: RemoteTrackPublication): 'remote-screen-share' | 'remote-camera' | null => { if (pub.kind !== Track.Kind.Video) return null; if (pub.source === Track.Source.ScreenShare) return 'remote-screen-share'; @@ -254,6 +255,17 @@ export function bindRoomEvents( ScreenSharePublicationMigration.dispose(); screenShareMigrationDisposer = null; }; + const bindScreenShareNegotiation = (): void => { + if (screenShareNegotiationBound) return; + screenShareNegotiationBound = true; + bindCodecNegotiation(); + bindScreenShareMigration(); + }; + const unbindScreenShareNegotiation = (): void => { + screenShareNegotiationBound = false; + unbindCodecNegotiation(); + unbindScreenShareMigration(); + }; const scheduleDecoderVerification = (track: RemoteTrack, pub: RemoteTrackPublication): void => { if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return; clearScreenShareDecoderVerification(pub.trackSid); @@ -307,8 +319,7 @@ export function bindRoomEvents( bindParticipantSpeakingEvents(room.localParticipant); room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant)); dependencies.remoteSpeaking.hydrateFromRoom(room); - bindCodecNegotiation(); - bindScreenShareMigration(); + bindScreenShareNegotiation(); dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf); dependencies.connection.markConnected(); await callbacks.onConnected(); @@ -335,8 +346,7 @@ export function bindRoomEvents( guard(attemptId, () => { participantSpeakingDisposers.forEach(({dispose}) => dispose()); participantSpeakingDisposers.clear(); - unbindCodecNegotiation(); - unbindScreenShareMigration(); + unbindScreenShareNegotiation(); clearAllScreenShareDecoderVerifications(); clearAllRemoteTrackLifecycleBindings(); dependencies.remoteSpeaking.clear(); @@ -610,4 +620,7 @@ export function bindRoomEvents( } }), ); + if (room.state === ConnectionState.Connected) { + guard(attemptId, bindScreenShareNegotiation)(); + } }