diff --git a/frontend/src/components/__tests__/meeting/MemberVideoBar.test.tsx b/frontend/src/components/__tests__/meeting/MemberVideoBar.test.tsx index fe8c0e87..5b4d3594 100644 --- a/frontend/src/components/__tests__/meeting/MemberVideoBar.test.tsx +++ b/frontend/src/components/__tests__/meeting/MemberVideoBar.test.tsx @@ -2,9 +2,12 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { useMeetingStore } from '@/store/useMeetingStore'; import { useMeetingSocketStore } from '@/store/useMeetingSocketStore'; import { MeetingMemberInfo } from '@/types/meeting'; -import { getMembersPerPage } from '@/utils/meeting'; +import { getMembersPerPage, getVideoConsumerIds } from '@/utils/meeting'; +import { MemberProviderInfo } from '@/types/meeting'; import { useWindowSize } from '@/hooks/useWindowSize'; import MemberVideoBar from '@/components/meeting/MemberVideoBar'; +import { Socket } from 'socket.io-client'; +import { Device, Transport } from 'mediasoup-client/types'; jest.mock('@/utils/meeting', () => ({ getMembersPerPage: jest.fn(), @@ -70,27 +73,66 @@ describe('', () => { }); it('1페이지(6슬롯)에서는 MyVideo가 보이고, 나머지 5칸에 멤버들이 표시된다', () => { + const mockSocket = { + emitWithAck: jest.fn().mockResolvedValue({ consumerInfos: [] }), + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as Socket; + + const mockTransport = { + id: 'test-transport', + } as unknown as Transport; + + const mockDevice = { + rtpCapabilities: {}, + } as unknown as Device; + + useMeetingSocketStore.setState({ + socket: mockSocket, + recvTransport: mockTransport, + device: mockDevice, + consumers: {}, + addConsumers: jest.fn(), + }); + // Given: 멤버 10명 const members: Record = {}; const orderedIds: string[] = []; + const producers: Record< + string, + { cam?: MemberProviderInfo | null; mic?: MemberProviderInfo | null } + > = {}; + for (let i = 1; i <= 10; i++) { const m = createMember(i); members[m.user_id] = m; orderedIds.push(m.user_id); + // also add dummy producer info to ensure it gets passed + producers[m.user_id] = { cam: null, mic: null }; } - useMeetingStore.setState({ members, orderedMemberIds: orderedIds }); + + useMeetingStore.setState({ + members, + orderedMemberIds: orderedIds, + memberProducers: producers, + }); render(); - // Then + // Then layout should still be correct expect(screen.getByTestId('my-video')).toBeInTheDocument(); - - // 1페이지 멤버: 총 6슬롯 - MyVideo(1) = 5명 const memberVideos = screen.getAllByTestId('member-video'); expect(memberVideos).toHaveLength(5); - expect(memberVideos[0]).toHaveTextContent('Member 1'); expect(memberVideos[4]).toHaveTextContent('Member 5'); + + // getVideoConsumerIds should be called with producers map + expect( + (getVideoConsumerIds as jest.Mock).mock.calls.length, + ).toBeGreaterThan(0); + const firstArg = (getVideoConsumerIds as jest.Mock).mock.calls[0][0]; + expect(firstArg).toBe(producers); }); it('다음 버튼을 누르면 2페이지로 이동하고 MyVideo 없이 6명의 멤버가 표시된다', () => { diff --git a/frontend/src/components/meeting/GlobalAudioPlayer.tsx b/frontend/src/components/meeting/GlobalAudioPlayer.tsx index c04d7505..4389d9e4 100644 --- a/frontend/src/components/meeting/GlobalAudioPlayer.tsx +++ b/frontend/src/components/meeting/GlobalAudioPlayer.tsx @@ -8,8 +8,12 @@ import { getAudioConsumerIds, getConsumerInstances } from '@/utils/meeting'; import { useEffect, useRef } from 'react'; export const GlobalAudioPlayer = () => { - const { members, memberStreams, setMemberStream, removeMemberStream } = - useMeetingStore(); + const { + memberProducers, + memberStreams, + setMemberStream, + removeMemberStream, + } = useMeetingStore(); const { socket, recvTransport, device, addConsumers } = useMeetingSocketStore(); @@ -25,7 +29,7 @@ export const GlobalAudioPlayer = () => { const currentConsumers = useMeetingSocketStore.getState().consumers; const { newAudioConsumers } = getAudioConsumerIds( - members, + memberProducers, currentConsumers, ); @@ -59,18 +63,16 @@ export const GlobalAudioPlayer = () => { addConsumers(newInstances); newInstances.forEach(({ producerId, consumer }) => { - const member = Object.values(members).find( - (m) => m.mic?.provider_id === producerId, + const entry = Object.entries(memberProducers).find( + ([, prod]) => prod.mic?.provider_id === producerId, ); - if (member) { - if (!member.mic?.is_paused) { - setMemberStream( - member.user_id, - 'mic', - new MediaStream([consumer.track]), - ); + const userId = entry ? entry[0] : undefined; + if (userId) { + const isPaused = memberProducers[userId]?.mic?.is_paused; + if (!isPaused) { + setMemberStream(userId, 'mic', new MediaStream([consumer.track])); } else { - removeMemberStream(member.user_id, 'mic'); + removeMemberStream(userId, 'mic'); } } }); @@ -96,7 +98,7 @@ export const GlobalAudioPlayer = () => { return () => { isCancelled = true; }; - }, [members, socket, recvTransport, device]); + }, [memberProducers, socket, recvTransport, device]); return (
diff --git a/frontend/src/components/meeting/MeetingRoom.tsx b/frontend/src/components/meeting/MeetingRoom.tsx index 00c2dd1d..108a10c9 100644 --- a/frontend/src/components/meeting/MeetingRoom.tsx +++ b/frontend/src/components/meeting/MeetingRoom.tsx @@ -51,6 +51,7 @@ export default function MeetingRoom() { removeMemberStream, setIsOpen, setScreenSharer, + setMemberProducer, } = useMeetingStore(); const { userId } = useUserStore(); @@ -262,8 +263,17 @@ export default function MeetingRoom() { nickname: producerNickname, is_paused: isPaused, producer_id: producerId, + kind, } = producerInfo; + // memberProducer 정보 업데이트 + setMemberProducer(userId, producerType as 'cam' | 'mic', { + provider_id: producerId, + kind, + type: producerType as 'cam' | 'mic', + is_paused: isPaused, + }); + const existingConsumer = useMeetingSocketStore.getState().consumers[producerId]; diff --git a/frontend/src/components/meeting/MemberVideoBar.tsx b/frontend/src/components/meeting/MemberVideoBar.tsx index 24cb0df1..a4ef98bd 100644 --- a/frontend/src/components/meeting/MemberVideoBar.tsx +++ b/frontend/src/components/meeting/MemberVideoBar.tsx @@ -24,12 +24,14 @@ export default function MemberVideoBar() { const { members, + memberProducers, setMemberStream, removeMemberStream, orderedMemberIds, pinnedMemberIds, lastSpeakerId, moveToFront, + setMemberProducer, } = useMeetingStore(); const { socket, recvTransport, device, addConsumers } = @@ -111,7 +113,11 @@ export default function MemberVideoBar() { pauseConsumerIds, visibleStreamTracks, hiddenUserIds, - } = getVideoConsumerIds(members, targetStreamMembers, currentConsumers); + } = getVideoConsumerIds( + memberProducers, + targetStreamMembers, + currentConsumers, + ); const allResumeIds = [...resumeConsumerIds]; @@ -138,11 +144,13 @@ export default function MemberVideoBar() { newConsumers.forEach(({ producerId, consumer }) => { allResumeIds.push(consumer.id); - const userId = Object.values(members).find( - (m) => m.cam?.provider_id === producerId, - )?.user_id; + const userEntry = Object.entries(memberProducers).find( + ([, prod]) => prod.cam?.provider_id === producerId, + ); + const userId = userEntry ? userEntry[0] : undefined; if (userId) { - if (members[userId].cam?.is_paused) { + const isPaused = memberProducers[userId]?.cam?.is_paused; + if (isPaused) { removeMemberStream(userId, 'cam'); } else { setMemberStream(userId, 'cam', new MediaStream([consumer.track])); @@ -157,13 +165,15 @@ export default function MemberVideoBar() { allResumeIds.forEach((consumerId) => { const consumer = currentConsumers[consumerId]; - const userId = Object.values(members).find( - (m) => m.cam?.provider_id === consumerId, - )?.user_id; + const userEntry = Object.entries(memberProducers).find( + ([, prod]) => prod.cam?.provider_id === consumerId, + ); + const userId = userEntry ? userEntry[0] : undefined; if (consumer && userId) { // 생산자가 진짜로 pause한 상태라면 연결하지 않음 - if (members[userId].cam?.is_paused) { + const isPaused = memberProducers[userId]?.cam?.is_paused; + if (isPaused) { removeMemberStream(userId, 'cam'); } else { setMemberStream(userId, 'cam', new MediaStream([consumer.track])); @@ -173,7 +183,8 @@ export default function MemberVideoBar() { // 이미 활성화되어 있던 트랙들도 확실하게 다시 세팅 visibleStreamTracks.forEach(({ userId, track }) => { - if (members[userId].cam?.is_paused) removeMemberStream(userId, 'cam'); + const isPaused = memberProducers[userId]?.cam?.is_paused; + if (isPaused) removeMemberStream(userId, 'cam'); else setMemberStream(userId, 'cam', new MediaStream([track])); }); } @@ -191,7 +202,7 @@ export default function MemberVideoBar() { socket, recvTransport, device, - members, + memberProducers, setMemberStream, removeMemberStream, addConsumers, @@ -223,6 +234,13 @@ export default function MemberVideoBar() { const onCameraProduced = async (producerInfo: ProducerInfo) => { const { user_id: userId, producer_id: producerId, type } = producerInfo; + setMemberProducer(userId, type as 'cam' | 'mic', { + provider_id: producerId, + kind: producerInfo.kind, + type: producerInfo.type as 'cam' | 'mic', + is_paused: producerInfo.is_paused ?? false, + }); + // 기존 컨슈머가 있는지 확인 (Resume 처리) const consumers = useMeetingSocketStore.getState().consumers; const existingConsumer = consumers[producerId]; @@ -251,6 +269,17 @@ export default function MemberVideoBar() { }; const onAlertProduced = (producerInfo: ProducerInfo) => { + setMemberProducer( + producerInfo.user_id, + producerInfo.type as 'cam' | 'mic', + { + provider_id: producerInfo.producer_id, + kind: producerInfo.kind, + type: producerInfo.type as 'cam' | 'mic', + is_paused: producerInfo.is_paused, + }, + ); + if (producerInfo.type === 'cam' && producerInfo.is_restart) { checkAndMoveToFront(producerInfo.user_id); } diff --git a/frontend/src/store/__tests__/useMeetingStore.test.ts b/frontend/src/store/__tests__/useMeetingStore.test.ts index d16fa005..9a381535 100644 --- a/frontend/src/store/__tests__/useMeetingStore.test.ts +++ b/frontend/src/store/__tests__/useMeetingStore.test.ts @@ -153,7 +153,7 @@ describe('useMeetingStore', () => { act(() => { addMember(createMember()); - setSpeaking('user1', true); + setSpeaking('user1', true, 6); }); const state = useMeetingStore.getState(); @@ -161,4 +161,84 @@ describe('useMeetingStore', () => { expect(state.speakingMembers['user1']).toBe(true); expect(state.lastSpeakerId).toBe('user1'); }); + + describe('producer metadata (memberProducers)', () => { + it('setMembers 초기화 시 cam/mic 필드가 복사된다', () => { + const { setMembers } = useMeetingStore.getState(); + const member: MeetingMemberInfo = createMember({ + cam: { + provider_id: 'cam-1', + kind: 'video', + type: 'cam', + is_paused: false, + }, + mic: { + provider_id: 'mic-1', + kind: 'audio', + type: 'mic', + is_paused: true, + }, + }); + + act(() => { + setMembers([member]); + }); + + const state = useMeetingStore.getState(); + expect(state.memberProducers['user1']?.cam).toEqual(member.cam); + expect(state.memberProducers['user1']?.mic).toEqual(member.mic); + }); + + it('addMember는 새로운 엔트리를 생성하고 cam/mic을 채운다', () => { + const { addMember } = useMeetingStore.getState(); + const member: MeetingMemberInfo = createMember({ + user_id: 'user2', + cam: { + provider_id: 'cam-2', + kind: 'video', + type: 'cam', + is_paused: false, + }, + }); + + act(() => { + addMember(member); + }); + + const state = useMeetingStore.getState(); + expect(state.memberProducers['user2']?.cam).toEqual(member.cam); + }); + + it('setMemberProducer는 필드만 업데이트 한다', () => { + const { setMemberProducer, setMembers } = useMeetingStore.getState(); + const member: MeetingMemberInfo = createMember({ user_id: 'user3' }); + + act(() => { + setMembers([member]); + setMemberProducer('user3', 'cam', { + provider_id: 'cam-x', + kind: 'video', + type: 'cam', + is_paused: true, + }); + }); + + const state = useMeetingStore.getState(); + expect(state.memberProducers['user3']?.cam?.provider_id).toBe('cam-x'); + expect(state.memberProducers['user3']?.cam?.is_paused).toBe(true); + }); + + it('removeMember는 관련 producer 정보도 삭제한다', () => { + const { addMember, removeMember } = useMeetingStore.getState(); + const member: MeetingMemberInfo = createMember({ user_id: 'user4' }); + + act(() => { + addMember(member); + removeMember('user4'); + }); + + const state = useMeetingStore.getState(); + expect(state.memberProducers['user4']).toBeUndefined(); + }); + }); }); diff --git a/frontend/src/store/useMeetingStore.ts b/frontend/src/store/useMeetingStore.ts index fa8fef87..b6e306a4 100644 --- a/frontend/src/store/useMeetingStore.ts +++ b/frontend/src/store/useMeetingStore.ts @@ -1,21 +1,24 @@ -import { - INITIAL_MEDIA_STATE, - INITIAL_MEETING_INFO, - VISIBLE_COUNT, -} from '@/constants/meeting'; +import { INITIAL_MEDIA_STATE, INITIAL_MEETING_INFO } from '@/constants/meeting'; import { MediaState, MediaType, MeetingInfo, MeetingMemberInfo, MemberStream, + MemberProviderInfo, } from '@/types/meeting'; -import { reorderMembers } from '@/utils/meeting'; import { create } from 'zustand'; interface MeetingState { media: MediaState; members: Record; + memberProducers: Record< + string, + { + cam?: MemberProviderInfo | null; + mic?: MemberProviderInfo | null; + } + >; memberStreams: Record; hasNewChat: boolean; screenSharer: { id: string; nickname: string } | null; @@ -39,6 +42,11 @@ interface MeetingActions { setMembers: (members: MeetingMemberInfo[]) => void; addMember: (member: MeetingMemberInfo) => void; removeMember: (userId: string) => void; + setMemberProducer: ( + userId: string, + type: 'cam' | 'mic', + info: MemberProviderInfo | null, + ) => void; setScreenSharer: (sharer: { id: string; nickname: string } | null) => void; setSpeaking: ( userId: string, @@ -78,6 +86,7 @@ interface MeetingActions { export const useMeetingStore = create((set) => ({ media: INITIAL_MEDIA_STATE, members: {}, + memberProducers: {}, memberStreams: {}, hasNewChat: false, screenSharer: null, @@ -104,9 +113,20 @@ export const useMeetingStore = create((set) => ({ ); const newOrderedIds = members.map((m) => m.user_id); + const newProducersMap: Record< + string, + { cam?: MemberProviderInfo; mic?: MemberProviderInfo } + > = {}; + members.forEach((m) => { + newProducersMap[m.user_id] = {}; + if (m.cam) newProducersMap[m.user_id].cam = m.cam; + if (m.mic) newProducersMap[m.user_id].mic = m.mic; + }); + return { members: newMembersMap, orderedMemberIds: newOrderedIds, + memberProducers: newProducersMap, }; }), addMember: (member) => @@ -115,10 +135,19 @@ export const useMeetingStore = create((set) => ({ const userId = member.user_id; const existingStream = state.memberStreams[member.user_id] || {}; + const existingProducers = state.memberProducers[userId] || {}; if (state.orderedMemberIds.includes(userId)) { return { members: { ...state.members, [userId]: member }, + memberProducers: { + ...state.memberProducers, + [userId]: { + ...existingProducers, + cam: member.cam, + mic: member.mic, + }, + }, }; } @@ -137,6 +166,13 @@ export const useMeetingStore = create((set) => ({ ...state.members, [userId]: member, }, + memberProducers: { + ...state.memberProducers, + [userId]: { + cam: member.cam, + mic: member.mic, + }, + }, memberStreams: { ...state.memberStreams, [userId]: existingStream, @@ -152,9 +188,12 @@ export const useMeetingStore = create((set) => ({ delete nextMemberStreams[userId]; const nextSpeakingMembers = { ...state.speakingMembers }; delete nextSpeakingMembers[userId]; + const nextProducers = { ...state.memberProducers }; + delete nextProducers[userId]; return { members: nextMembers, + memberProducers: nextProducers, memberStreams: nextMemberStreams, speakingMembers: nextSpeakingMembers, orderedMemberIds: state.orderedMemberIds.filter((id) => id !== userId), @@ -163,6 +202,14 @@ export const useMeetingStore = create((set) => ({ state.lastSpeakerId === userId ? null : state.lastSpeakerId, }; }), + setMemberProducer: (userId, type, info) => + set((state) => { + const existing = state.memberProducers[userId] || {}; + const updated = { ...existing, [type]: info }; + return { + memberProducers: { ...state.memberProducers, [userId]: updated }, + }; + }), setScreenSharer: (sharer) => set(() => ({ screenSharer: sharer })), setSpeaking: (userId, isSpeaking, visibleCount) => set((state) => { diff --git a/frontend/src/types/meeting.ts b/frontend/src/types/meeting.ts index b4b1737a..15142f99 100644 --- a/frontend/src/types/meeting.ts +++ b/frontend/src/types/meeting.ts @@ -51,7 +51,7 @@ export interface ProviderToolInfo { } // 회의 멤버 관련 타입 -interface MemberProviderInfo { +export interface MemberProviderInfo { provider_id: string; kind: 'audio' | 'video'; type: 'mic' | 'cam'; @@ -85,7 +85,7 @@ export interface ProducerInfo { type: MediaType; nickname: string; is_paused: boolean; - is_restart : boolean; + is_restart: boolean; } export type MemberStream = Partial>; diff --git a/frontend/src/utils/__tests__/meeting.test.ts b/frontend/src/utils/__tests__/meeting.test.ts index 3e8e477c..ed8d4e66 100644 --- a/frontend/src/utils/__tests__/meeting.test.ts +++ b/frontend/src/utils/__tests__/meeting.test.ts @@ -1,9 +1,10 @@ import { Consumer } from 'mediasoup-client/types'; -import { MeetingMemberInfo } from '@/types/meeting'; +import { MeetingMemberInfo, MemberProviderInfo } from '@/types/meeting'; import { getAudioConsumerIds, getMembersPerPage, getVideoConsumerIds, + MemberProducers, reorderMembers, } from '@/utils/meeting'; @@ -73,11 +74,23 @@ describe('reorderMembers', () => { }); describe('getVideoConsumerIds', () => { - const member = (id: string, camId?: string): MeetingMemberInfo => - ({ - user_id: id, - cam: camId ? { provider_id: camId } : undefined, - }) as MeetingMemberInfo; + const visible = (id: string): MeetingMemberInfo => + ({ user_id: id }) as MeetingMemberInfo; + const producersFor = ( + id: string, + camId?: string, + ): Record => { + const entry: Record = {}; + if (camId) { + entry.cam = { + provider_id: camId, + kind: 'video', + type: 'cam', + is_paused: false, + }; + } + return { [id]: entry }; + }; const consumer = (id: string): Consumer => ({ @@ -87,10 +100,8 @@ describe('getVideoConsumerIds', () => { it('visible 멤버 중 consumer가 없으면 newVideoConsumers에 포함된다', () => { const result = getVideoConsumerIds( - { - a: member('a', 'cam-a'), - }, - [member('a', 'cam-a')], + producersFor('a', 'cam-a'), + [visible('a')], {}, ); @@ -99,10 +110,8 @@ describe('getVideoConsumerIds', () => { it('visible 멤버의 consumer는 resume 대상이 된다', () => { const result = getVideoConsumerIds( - { - a: member('a', 'cam-a'), - }, - [member('a', 'cam-a')], + producersFor('a', 'cam-a'), + [visible('a')], { 'cam-a': consumer('consumer-a'), }, @@ -113,15 +122,9 @@ describe('getVideoConsumerIds', () => { }); it('hidden 멤버의 consumer는 pause 대상이 된다', () => { - const result = getVideoConsumerIds( - { - a: member('a', 'cam-a'), - }, - [], - { - 'cam-a': consumer('consumer-a'), - }, - ); + const result = getVideoConsumerIds(producersFor('a', 'cam-a'), [], { + 'cam-a': consumer('consumer-a'), + }); expect(result.pauseConsumerIds).toEqual(['consumer-a']); expect(result.hiddenUserIds).toEqual(['a']); @@ -129,17 +132,27 @@ describe('getVideoConsumerIds', () => { }); describe('getAudioConsumerIds', () => { - const member = (id: string, micId?: string): MeetingMemberInfo => - ({ - user_id: id, - mic: micId ? { provider_id: micId } : undefined, - }) as MeetingMemberInfo; + const producersFor = ( + id: string, + micId?: string, + ): Record => { + const entry: Record = {}; + if (micId) { + entry.mic = { + provider_id: micId, + kind: 'audio', + type: 'mic', + is_paused: false, + }; + } + return { [id]: entry }; + }; it('consumer가 없는 mic만 newAudioConsumers에 포함된다', () => { const result = getAudioConsumerIds( { - a: member('a', 'mic-a'), - b: member('b', 'mic-b'), + ...producersFor('a', 'mic-a'), + ...producersFor('b', 'mic-b'), }, { 'mic-b': {} as Consumer, diff --git a/frontend/src/utils/meeting.ts b/frontend/src/utils/meeting.ts index a9d8bc2d..ca5a90b2 100644 --- a/frontend/src/utils/meeting.ts +++ b/frontend/src/utils/meeting.ts @@ -1,12 +1,20 @@ -import { ConsumerInfo, MeetingMemberInfo } from '@/types/meeting'; +import { + ConsumerInfo, + MeetingMemberInfo, + MemberProviderInfo, +} from '@/types/meeting'; import { Consumer, Transport } from 'mediasoup-client/types'; +export interface MemberProducers { + cam?: MemberProviderInfo | null; + mic?: MemberProviderInfo | null; +} + export const getVideoConsumerIds = ( - members: Record, + producers: Record, visibleMembers: MeetingMemberInfo[], consumers: Record, ) => { - const allMembers = Object.values(members); const visibleIdsSet = new Set(visibleMembers.map((member) => member.user_id)); const newVideoConsumers: string[] = []; @@ -16,27 +24,21 @@ export const getVideoConsumerIds = ( const visibleStreamTracks: { userId: string; track: MediaStreamTrack }[] = []; const hiddenUserIds: string[] = []; - allMembers.forEach((member) => { - const producerId = member.cam?.provider_id; + Object.entries(producers).forEach(([userId, prod]) => { + const producerId = prod.cam?.provider_id; if (!producerId) return; const consumer = consumers[producerId]; - if (visibleIdsSet.has(member.user_id)) { - // 새로운 consume 대상 + if (visibleIdsSet.has(userId)) { if (!consumer) { newVideoConsumers.push(producerId); } else { - // resume 대상 계산 resumeConsumerIds.push(consumer.id); - visibleStreamTracks.push({ - userId: member.user_id, - track: consumer.track, - }); + visibleStreamTracks.push({ userId, track: consumer.track }); } } else { - // pause 대상 계산 - hiddenUserIds.push(member.user_id); + hiddenUserIds.push(userId); if (consumer) { pauseConsumerIds.push(consumer.id); } @@ -53,16 +55,14 @@ export const getVideoConsumerIds = ( }; export const getAudioConsumerIds = ( - members: Record, + producers: Record, consumers: Record, ) => { - const allMembers = Object.values(members); const newAudioConsumers: string[] = []; - allMembers.forEach((member) => { - const micId = member.mic?.provider_id; + Object.values(producers).forEach((prod) => { + const micId = prod.mic?.provider_id; if (!micId) return; - if (!consumers[micId]) { newAudioConsumers.push(micId); }