= {};
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);
}