diff --git a/frontEnd/.eslintrc.cjs b/frontEnd/.eslintrc.cjs index 15e4e10..fbebe49 100644 --- a/frontEnd/.eslintrc.cjs +++ b/frontEnd/.eslintrc.cjs @@ -29,5 +29,6 @@ module.exports = { 'no-param-reassign': 'off', 'react/no-array-index-key': 'off', 'react/no-danger': 'off', + 'react/require-default-props': 'off', }, }; diff --git a/frontEnd/package-lock.json b/frontEnd/package-lock.json index 6f358b6..1863ebc 100644 --- a/frontEnd/package-lock.json +++ b/frontEnd/package-lock.json @@ -15,7 +15,8 @@ "react-router-dom": "^6.18.0", "socket.io-client": "^4.7.2", "uuid": "^9.0.1", - "yjs": "^13.6.8" + "yjs": "^13.6.8", + "zustand": "^4.4.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.4", @@ -2426,13 +2427,13 @@ "version": "15.7.9", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.2.37", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz", "integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -2452,7 +2453,7 @@ "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==", - "dev": true + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.4", @@ -3853,7 +3854,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -11175,6 +11176,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11603,6 +11612,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.6.tgz", + "integrity": "sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontEnd/package.json b/frontEnd/package.json index 9034ba2..f6ee785 100644 --- a/frontEnd/package.json +++ b/frontEnd/package.json @@ -18,7 +18,8 @@ "react-router-dom": "^6.18.0", "socket.io-client": "^4.7.2", "uuid": "^9.0.1", - "yjs": "^13.6.8" + "yjs": "^13.6.8", + "zustand": "^4.4.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.4", diff --git a/frontEnd/src/assets/micOff.svg b/frontEnd/src/assets/micOff.svg new file mode 100644 index 0000000..5f1b52d --- /dev/null +++ b/frontEnd/src/assets/micOff.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/frontEnd/src/assets/micOn.svg b/frontEnd/src/assets/micOn.svg new file mode 100644 index 0000000..4daa47c --- /dev/null +++ b/frontEnd/src/assets/micOn.svg @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/frontEnd/src/assets/videoOff.svg b/frontEnd/src/assets/videoOff.svg new file mode 100644 index 0000000..e9fcee8 --- /dev/null +++ b/frontEnd/src/assets/videoOff.svg @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/frontEnd/src/assets/videoOn.svg b/frontEnd/src/assets/videoOn.svg new file mode 100644 index 0000000..5485ed7 --- /dev/null +++ b/frontEnd/src/assets/videoOn.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/frontEnd/src/components/Button.tsx b/frontEnd/src/components/Button.tsx index 9ae4650..5c4abc5 100644 --- a/frontEnd/src/components/Button.tsx +++ b/frontEnd/src/components/Button.tsx @@ -5,8 +5,11 @@ type ButtonProps = { onClick: () => void; fontSize: string; }; +function Button({ children }: { children: ReactNode }) { + return
{children}
; +} -export default function Button({ children, onClick, fontSize }: ButtonProps) { +function Default({ children, onClick, fontSize }: ButtonProps) { return ( + ); +} + +export default Object.assign(Button, { + Default, + Full, +}); diff --git a/frontEnd/src/components/MediaSelector.tsx b/frontEnd/src/components/MediaSelector.tsx new file mode 100644 index 0000000..e45afb4 --- /dev/null +++ b/frontEnd/src/components/MediaSelector.tsx @@ -0,0 +1,32 @@ +export default function MediaSelector({ + stream, + optionsData, + setFunc, +}: { + stream: MediaStream; + optionsData: MediaDeviceInfo[]; + setFunc: React.Dispatch>; +}) { + const onChange = (e: React.ChangeEvent) => { + setFunc(e.target.value); + }; + return ( + + ); +} diff --git a/frontEnd/src/components/SettingVideo.tsx b/frontEnd/src/components/SettingVideo.tsx new file mode 100644 index 0000000..daa3651 --- /dev/null +++ b/frontEnd/src/components/SettingVideo.tsx @@ -0,0 +1,88 @@ +import { ReactNode, useState } from 'react'; +import { MediaObject } from '@/hooks/useMedia'; +import MediaSelector from './MediaSelector'; +import Video from './Video'; +import micOnSVG from '@/assets/micOn.svg'; +import micOffSVG from '@/assets/micOff.svg'; +import videoOffSVG from '@/assets/videoOff.svg'; +import videoOnSVG from '@/assets/videoOn.svg'; +import useSpeaker from '@/stores/useSpeaker'; + +function ControlButton({ onClick, style, children }: { onClick: () => void; style: Record; children: ReactNode }) { + return ( + + ); +} + +export default function SettingVideo({ mediaObject }: { mediaObject: MediaObject }) { + const { stream, camera, mic, speaker } = mediaObject; + const setSpeaker = useSpeaker((state) => state.setSpeaker); + const [micOn, setMicOn] = useState(true); + + const [videoOn, setVideoOn] = useState(true); + + const offVideo = () => { + stream?.getVideoTracks().forEach((track) => { + track.enabled = !track.enabled; + }); + }; + + const muteMic = () => { + stream?.getAudioTracks().forEach((track) => { + track.enabled = !track.enabled; + }); + }; + const handleMicClick = () => { + setMicOn((prev) => !prev); + muteMic(); + }; + const handleVideoClick = () => { + setVideoOn((prev) => !prev); + offVideo(); + }; + const selector = [ + { list: camera.list, setFunc: camera.setCamera }, + { list: mic.list, setFunc: mic.setMic }, + { list: speaker.list, setFunc: setSpeaker }, + ]; + return ( + stream && ( +
+
+
+
+ {selector.map( + ({ list, setFunc }, i) => + list && ( + >} + /> + ), + )} +
+
+ ) + ); +} diff --git a/frontEnd/src/components/Settings.tsx b/frontEnd/src/components/Settings.tsx new file mode 100644 index 0000000..23b6415 --- /dev/null +++ b/frontEnd/src/components/Settings.tsx @@ -0,0 +1,35 @@ +import { MediaObject } from '@/hooks/useMedia'; +import Button from './Button'; +import SettingVideo from './SettingVideo'; + +export default function Setting({ + mediaObject, + setSetting, +}: { + mediaObject: MediaObject; + setSetting: React.Dispatch>; +}) { + return ( +
+
+
+ logo +
AlgoITNi
+
+
+
+
+ +
+
+
+
참여할 준비가 되셨나요?
+ setSetting(true)} fontSize="1.8vw"> + 참여 + +
+
+
+
+ ); +} diff --git a/frontEnd/src/components/Video.tsx b/frontEnd/src/components/Video.tsx new file mode 100644 index 0000000..6f85587 --- /dev/null +++ b/frontEnd/src/components/Video.tsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react'; +import useSpeaker from '@/stores/useSpeaker'; + +export default function Video({ stream, muted = false }: { stream: MediaStream; muted?: boolean }) { + const videoRef = useRef(null); + const speaker = useSpeaker((state) => state.speaker); + + useEffect(() => { + if (!videoRef.current) return; + + videoRef.current.srcObject = stream; + // setSinkId가 experimental method라서 typescript에 type이 없어서 이렇게 사용하였음 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (videoRef.current as any).setSinkId?.(speaker); + }, [stream]); + + return ( + + ); +} diff --git a/frontEnd/src/constants/env.ts b/frontEnd/src/constants/env.ts new file mode 100644 index 0000000..d639e17 --- /dev/null +++ b/frontEnd/src/constants/env.ts @@ -0,0 +1,3 @@ +const { VITE_SOCKET_URL, VITE_STUN_URL, VITE_TURN_URL, VITE_TURN_USERNAME, VITE_TURN_CREDENTIAL } = import.meta.env; + +export { VITE_SOCKET_URL, VITE_STUN_URL, VITE_TURN_URL, VITE_TURN_USERNAME, VITE_TURN_CREDENTIAL }; diff --git a/frontEnd/src/hooks/tests/useInput.test.ts b/frontEnd/src/hooks/tests/useInput.test.ts new file mode 100644 index 0000000..c6ead95 --- /dev/null +++ b/frontEnd/src/hooks/tests/useInput.test.ts @@ -0,0 +1,21 @@ +import { act, renderHook } from '@testing-library/react'; +import useInput from '../useInput'; + +describe('useInput 기능테스트', () => { + it('useInput의 초기값은 입력한 값으로 초기화 된다.', () => { + const { result } = renderHook(() => useInput('initial')); + expect(result.current.inputValue).toBe('initial'); + }); + + it('onChange가 일어나면 event.target.value로 값을 변경한다.', () => { + const { result } = renderHook(() => useInput('initial')); + + const mockEvent = { target: { value: 'changeValue' } } as React.ChangeEvent; + + act(() => { + result.current.onChange(mockEvent); + }); + + expect(result.current.inputValue).toBe('changeValue'); + }); +}); diff --git a/frontEnd/src/hooks/tests/useMedia.test.ts b/frontEnd/src/hooks/tests/useMedia.test.ts new file mode 100644 index 0000000..91919c2 --- /dev/null +++ b/frontEnd/src/hooks/tests/useMedia.test.ts @@ -0,0 +1,85 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useMedia from '../useMedia'; + +const mockGetUserMedia = jest.fn(async () => { + const stream = { value: 'testValue' } as unknown as MediaStream; + return new Promise((resolve) => { + resolve(stream); + }); +}); + +const mockEnumerateDevices = jest.fn(async () => { + const devices = [ + { kind: 'videoinput', value: 'testVideo' }, + { kind: 'audioinput', value: 'testMic' }, + { kind: 'audiooutput', value: 'testSpeaker' }, + ] as unknown as MediaDeviceInfo[]; + + return new Promise((resolve) => { + resolve(devices); + }); +}); + +const mockChangeGetUserMedia = jest.fn(async () => { + const stream = { value: 'changeValue' } as unknown as MediaStream; + return new Promise((resolve) => { + resolve(stream); + }); +}); + +const mockChangeEnumerateDevices = jest.fn(async () => { + const devices = [ + { kind: 'videoinput', value: 'changeVideo' }, + { kind: 'audioinput', value: 'changeMic' }, + { kind: 'audiooutput', value: 'changeSpeaker' }, + ] as unknown as MediaDeviceInfo[]; + + return new Promise((resolve) => { + resolve(devices); + }); +}); + +Object.defineProperty(global.navigator, 'mediaDevices', { + value: { + getUserMedia: mockGetUserMedia, + enumerateDevices: mockEnumerateDevices, + }, + writable: true, +}); + +describe('useMedia 기능 테스트', () => { + it('navigator.mediaDevices를 기반으로 가져온 stream으로 userStream을 업데이트할 수 있다.', () => { + const { result } = renderHook(() => useMedia()); + waitFor(() => expect(result.current.stream).toStrictEqual({ value: 'testValue' })); + }); + + it('enumerateDevice를 통해 cameraList, micList, speakerList를 업데이트한다.', () => { + const { result } = renderHook(() => useMedia()); + waitFor(() => { + expect(result.current.camera.list).toStrictEqual([{ kind: 'videoinput', value: 'testVideo' }]); + expect(result.current.mic.list).toStrictEqual([{ kind: 'audioinput', value: 'testMic' }]); + expect(result.current.speaker.list).toStrictEqual([{ kind: 'audiooutput', value: 'testSpeaker' }]); + }); + }); + + it('selectedCamera, selectedMic, speaker가 바뀌면 useEffect가 재실행되어 enumerateDevice를 업데이트한다.', () => { + const { result, rerender } = renderHook(() => useMedia()); + Object.defineProperty(global.navigator, 'mediaDevices', { + value: { + getUserMedia: mockChangeGetUserMedia, + enumerateDevices: mockChangeEnumerateDevices, + }, + }); + + rerender({ + selectedCamera: 'change', + }); + + waitFor(() => { + expect(result.current.stream).toStrictEqual({ value: 'changeValue' }); + expect(result.current.camera.list).toStrictEqual([{ kind: 'videoinput', value: 'changeVideo' }]); + expect(result.current.mic.list).toStrictEqual([{ kind: 'audioinput', value: 'changeMic' }]); + expect(result.current.speaker.list).toStrictEqual([{ kind: 'audiooutput', value: 'changeSpeaker' }]); + }); + }); +}); diff --git a/frontEnd/src/hooks/useMedia.ts b/frontEnd/src/hooks/useMedia.ts new file mode 100644 index 0000000..ddb0923 --- /dev/null +++ b/frontEnd/src/hooks/useMedia.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import useSpeaker from '@/stores/useSpeaker'; + +export interface MediaObject { + stream: MediaStream | undefined; + camera: { + list: MediaDeviceInfo[] | undefined; + setCamera: React.Dispatch>; + }; + mic: { + list: MediaDeviceInfo[] | undefined; + setMic: React.Dispatch>; + }; + speaker: { + list: MediaDeviceInfo[] | undefined; + }; +} + +export default function useMedia(): MediaObject { + const [userStream, setUserStream] = useState(); + const [cameraList, setCameraList] = useState(); + const [micList, setMicList] = useState(); + const [speakerList, setSpeakerList] = useState(); + const [selectedCamera, setSelectedCamera] = useState(''); + const [selectedMic, setSelectedMic] = useState(''); + const { speaker } = useSpeaker((state) => state); + + useEffect(() => { + navigator.mediaDevices + .getUserMedia({ + video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true, + audio: selectedMic ? { deviceId: { exact: selectedMic } } : true, + }) + .then((stream) => { + setUserStream(stream); + }); + + navigator.mediaDevices.enumerateDevices().then((res) => { + setCameraList(res.filter((mediaDevice) => mediaDevice.kind === 'videoinput')); + setMicList(res.filter((mediaDevice) => mediaDevice.kind === 'audioinput')); + setSpeakerList(res.filter((mediaDevice) => mediaDevice.kind === 'audiooutput')); + }); + }, [selectedCamera, selectedMic, speaker]); + + return { + stream: userStream, + camera: { list: cameraList, setCamera: setSelectedCamera }, + mic: { list: micList, setMic: setSelectedMic }, + speaker: { list: speakerList }, + }; +} diff --git a/frontEnd/src/hooks/useRoom.ts b/frontEnd/src/hooks/useRoom.ts index a1d6ace..770cf96 100644 --- a/frontEnd/src/hooks/useRoom.ts +++ b/frontEnd/src/hooks/useRoom.ts @@ -1,53 +1,60 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { io } from 'socket.io-client/debug'; import { SOCKET_EMIT_EVENT, SOCKET_RECEIVE_EVENT } from '@/constants/socketEvents'; +import { VITE_SOCKET_URL, VITE_STUN_URL, VITE_TURN_CREDENTIAL, VITE_TURN_URL, VITE_TURN_USERNAME } from '@/constants/env'; -const useRoom = (roomId: string) => { +const RTCConnections: Record = {}; + +const useRoom = (roomId: string, localStream: MediaStream, isSetting: boolean) => { + const [isConnect, setIsConnect] = useState(false); const [streamList, setStreamList] = useState<{ id: string; stream: MediaStream }[]>([]); const [dataChannels, setDataChannels] = useState<{ id: string; dataChannel: RTCDataChannel }[]>([]); + const socket = io(VITE_SOCKET_URL); - const videoRef = useRef(null); - const localStream = useRef(null); - - const RTCConnections: Record = {}; - - const socket = io(`${import.meta.env.VITE_SOCKET_URL}`); + const socketConnect = () => { + socket.connect(); + socket.emit(SOCKET_EMIT_EVENT.JOIN_ROOM, { + room: roomId, + }); + setIsConnect(true); + }; useEffect(() => { - navigator.mediaDevices - .getUserMedia({ - video: true, - }) - .then((video) => { - if (videoRef.current) videoRef.current.srcObject = video; - - localStream.current = video; - - socket.connect(); - socket.emit(SOCKET_EMIT_EVENT.JOIN_ROOM, { - room: roomId, + if (localStream && isSetting) { + if (!isConnect) socketConnect(); + else { + Object.values(RTCConnections).forEach(async (peerConnection) => { + const videoSender = peerConnection.getSenders().find((sender) => sender.track?.kind === 'video'); + const audioSender = peerConnection.getSenders().find((sender) => sender.track?.kind === 'audio'); + const currentTracks = localStream.getTracks(); + if (currentTracks) { + const currentVideoTrack = currentTracks.find((track) => track?.kind === 'video'); + const currentAudioTrack = currentTracks.find((track) => track?.kind === 'audio'); + if (currentVideoTrack) await videoSender?.replaceTrack(currentVideoTrack); + if (currentAudioTrack) await audioSender?.replaceTrack(currentAudioTrack); + } }); - }); - }, []); + } + } + }, [localStream, isSetting]); const createPeerConnection = (socketId: string): RTCPeerConnection => { const RTCConnection = new RTCPeerConnection({ iceServers: [ - { urls: import.meta.env.VITE_STUN_URL }, + { urls: VITE_STUN_URL }, { - urls: import.meta.env.VITE_TURN_URL, - username: import.meta.env.VITE_TURN_USERNAME, - credential: import.meta.env.VITE_TURN_CREDENTIAL, + urls: VITE_TURN_URL, + username: VITE_TURN_USERNAME, + credential: VITE_TURN_CREDENTIAL, }, ], }); const newDataChannel = RTCConnection.createDataChannel('edit', { negotiated: true, id: 0 }); - setDataChannels((prev) => [...prev, { id: socketId, dataChannel: newDataChannel }]); - if (localStream.current) { - localStream.current.getTracks().forEach((track) => { - RTCConnection.addTrack(track, localStream.current as MediaStream); + if (localStream) { + localStream.getTracks().forEach((track) => { + RTCConnection.addTrack(track, localStream); }); } @@ -61,8 +68,12 @@ const useRoom = (roomId: string) => { }); RTCConnection.addEventListener('track', (e) => { - setStreamList((prev) => [...prev, { id: socketId, stream: e.streams[0] }]); + setStreamList((prev) => { + const newArray = [...prev].filter(({ id }) => id !== socketId); + return [...newArray, { id: socketId, stream: e.streams[0] }]; + }); }); + setDataChannels((prev) => [...prev, { id: socketId, dataChannel: newDataChannel }]); return RTCConnection; }; @@ -71,7 +82,6 @@ const useRoom = (roomId: string) => { data.users.forEach((user) => { RTCConnections[user.id] = createPeerConnection(user.id); }); - Object.entries(RTCConnections).forEach(async ([key, value]) => { const offer = await value.createOffer({ offerToReceiveAudio: true, @@ -123,7 +133,7 @@ const useRoom = (roomId: string) => { setStreamList((prev) => prev.filter((stream) => stream.id !== data.id)); }); - return { videoRef, socket, RTCConnections, streamList, dataChannels }; + return { socket, streamList, dataChannels }; }; export default useRoom; diff --git a/frontEnd/src/pages/Home.tsx b/frontEnd/src/pages/Home.tsx index c7e6a80..a4b0660 100644 --- a/frontEnd/src/pages/Home.tsx +++ b/frontEnd/src/pages/Home.tsx @@ -28,9 +28,9 @@ export default function Home() {
AlgoITNi를 통해 동료, 친구와 함께 알고리즘을 학습해봐요!
- + (null); - - useEffect(() => { - if (!videoRef.current) return; - - videoRef.current.srcObject = stream; - }, []); - - return