Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 사용자는 로비 페이지에서 미디어 장치를 변경할 수 있다. #216

Merged
merged 6 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/frontend/src/assets/images/DropBoxArrowDown.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/frontend/src/assets/images/DropBoxArrowUp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import DropBox from '@/components/lobbyPage/DropBox';
import { useLocalStreamStore } from '@/store/localStreamStore';

function MediaSourcePicker() {
return (
<div className="flex flex-col gap-4 text-black left-0 w-full">
<DropBox title={'카메라 설정'} type="video" />
<DropBox title={'마이크 설정'} type="audio" />
</div>
);
}

export default MediaSourcePicker;
87 changes: 87 additions & 0 deletions packages/frontend/src/components/lobbyPage/DropBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState, useRef, useEffect } from 'react';
import ArrowDownIcon from '@/assets/images/DropBoxArrowDown.svg?react';
import ArrowUpIcon from '@/assets/images/DropBoxArrowUp.svg?react';
import { changeMediaDevice } from '@/utils/videoStreamUtils';
import { useLocalStreamStore } from '@/store/localStreamStore';

interface IDropBoxProps {
title: string;
type: 'video' | 'audio';
}

function DropBox({ title, type }: IDropBoxProps) {
const [isOpen, setIsOpen] = useState(false);
const dropBoxRef = useRef<HTMLDivElement>(null);
const videoDevices = useLocalStreamStore((state) => state.videoDevices);
const audioInputDevices = useLocalStreamStore((state) => state.audioInputDevices);
const currentVideoDevice = useLocalStreamStore((state) => state.currentVideoDevice);
const currentAudioDevice = useLocalStreamStore((state) => state.currentAudioDevice);
const targetMedia = type === 'video' ? currentVideoDevice : currentAudioDevice;
const targetMediaList = type === 'video' ? videoDevices : audioInputDevices;
const targetMediaLabel = targetMedia?.label || targetMediaList?.[0]?.label;

function handleClick() {
if (!targetMediaList?.length) return;
setIsOpen(!isOpen);
}

function handleDeviceChange(device: MediaDeviceInfo) {
if (device.kind === 'videoinput') {
changeMediaDevice({ videoDeviceId: device.deviceId, audioDeviceId: '' });
} else {
changeMediaDevice({ videoDeviceId: '', audioDeviceId: device.deviceId });
}
}

useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (dropBoxRef.current && !dropBoxRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}

document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);

return (
<div
ref={dropBoxRef}
className="flex flex-col gap-2 cursor-pointer select-none"
onClick={handleClick}
>
<span className="text-sm font-semibold">{title}</span>
<div className="flex items-center gap-2 relative">
<div className="w-full border border-black rounded-lg px-4 py-2 flex items-center justify-between">
{targetMediaList?.length ? (
targetMediaLabel
) : (
<span className="text-gray-500">장치가 없습니다</span>
)}
{isOpen ? <ArrowUpIcon /> : <ArrowDownIcon />}
</div>
{isOpen && (
<div className="border border-black rounded-lg p-2 absolute left-0 right-0 top-full mt-1 bg-white z-10">
{targetMediaList?.map((device) => {
if (device.deviceId.includes('communications')) return null;
return (
<div
key={device.deviceId}
className="p-2 hover:bg-gray-200 cursor-pointer rounded-lg flex items-center gap-2"
onClick={() => handleDeviceChange(device)}
>
{targetMediaLabel === device.label && <span className="text-green-500">✅</span>}
{device.label}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

export default DropBox;
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import VideoStream from '@/components/gamePage/stream/VideoStream';
import { useLocalStreamStore } from '@/store/localStreamStore';
import CameraSettingButton from '@/components/gamePage/stream/CameraSettingButton';
import MikeSettingButton from '@/components/gamePage/stream/MikeSettingButton';
import MediaSourcePicker from '@/components/gamePage/stream/MediaSourcePicker';

interface IVideoAudioTestModalProps {
onClose: () => void;
Expand All @@ -15,9 +16,14 @@ export default function VideoAudioTestModal({ onClose, title }: IVideoAudioTestM
<div className="min-w-[600px] max-w-lg p-8 bg-white shadow-lg rounded-2xl flex flex-col items-center gap-8">
<h2 className="mb-4 text-xl font-semibold text-center text-gray-900">{title}</h2>
<VideoStream userName="카메라 테스트" stream={localStream} />
<MediaSourcePicker />
<div className="flex gap-4">
<CameraSettingButton iconColor="text-black" />
<MikeSettingButton iconColor="text-black" />
<div className="select-none">
<CameraSettingButton iconColor="text-black" />
</div>
<div className="select-none">
<MikeSettingButton iconColor="text-black" />
</div>
</div>
<button
onClick={onClose}
Expand Down
16 changes: 16 additions & 0 deletions packages/frontend/src/store/localStreamStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ import { create } from 'zustand';

interface ILocalStreamState {
localStream: MediaStream | null;
videoDevices: MediaDeviceInfo[];
audioInputDevices: MediaDeviceInfo[];
currentVideoDevice: MediaDeviceInfo | null;
currentAudioDevice: MediaDeviceInfo | null;
setLocalStream: (stream: MediaStream | null) => void;
setVideoDevices: (devices: MediaDeviceInfo[]) => void;
setAudioInputDevices: (devices: MediaDeviceInfo[]) => void;
setCurrentVideoDevice: (device: MediaDeviceInfo | null) => void;
setCurrentAudioDevice: (device: MediaDeviceInfo | null) => void;
}

export const useLocalStreamStore = create<ILocalStreamState>((set) => ({
localStream: null,
videoDevices: [],
audioInputDevices: [],
currentVideoDevice: null,
currentAudioDevice: null,
setLocalStream: (stream) => set({ localStream: stream }),
setVideoDevices: (devices) => set({ videoDevices: devices }),
setAudioInputDevices: (devices) => set({ audioInputDevices: devices }),
setCurrentVideoDevice: (device) => set({ currentVideoDevice: device }),
setCurrentAudioDevice: (device) => set({ currentAudioDevice: device }),
}));
56 changes: 56 additions & 0 deletions packages/frontend/src/utils/videoStreamUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useLocalStreamStore } from '@/store/localStreamStore';

interface IMediaStreamProps {
videoDeviceId?: string;
audioDeviceId?: string;
}

export async function getVideoStream() {
const videoConfig = {
video: {
Expand All @@ -15,8 +20,59 @@ export async function getVideoStream() {
try {
const localStream = await navigator.mediaDevices.getUserMedia(videoConfig);
useLocalStreamStore.getState().setLocalStream(localStream);
await handleMediaDevices({ videoDeviceId: '', audioDeviceId: '' });
return localStream;
} catch (error) {
return null;
}
}

async function getTargetMediaDevices({ videoDeviceId, audioDeviceId }: IMediaStreamProps) {
const currentVideoDevice = useLocalStreamStore.getState().currentVideoDevice;
const currentAudioDevice = useLocalStreamStore.getState().currentAudioDevice;
const videoId = videoDeviceId || currentVideoDevice?.deviceId;
const audioId = audioDeviceId || currentAudioDevice?.deviceId;
const videoConfig = {
video: { deviceId: videoId },
audio: {
deviceId: audioId,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
};
try {
const localStream = await navigator.mediaDevices.getUserMedia(videoConfig);
await handleMediaDevices({ videoDeviceId, audioDeviceId });
return localStream;
} catch (error) {
return null;
}
}

async function handleMediaDevices({ videoDeviceId, audioDeviceId }: IMediaStreamProps) {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputDevices = devices.filter((device) => device.kind === 'audioinput');
const videoDevices = devices.filter((device) => device.kind === 'videoinput');
const currentVideoDevice = videoDeviceId
? videoDevices.find((device) => device.deviceId === videoDeviceId)
: null;
const currentAudioDevice = audioDeviceId
? audioInputDevices.find((device) => device.deviceId === audioDeviceId)
: null;
useLocalStreamStore.getState().setAudioInputDevices(audioInputDevices);
useLocalStreamStore.getState().setVideoDevices(videoDevices);
if (videoDeviceId)
useLocalStreamStore.getState().setCurrentVideoDevice(currentVideoDevice || null);
if (audioDeviceId)
useLocalStreamStore.getState().setCurrentAudioDevice(currentAudioDevice || null);
}

export async function changeMediaDevice({ videoDeviceId, audioDeviceId }: IMediaStreamProps) {
const localStream = useLocalStreamStore.getState().localStream;
const videoTrack = localStream?.getVideoTracks()[0];
if (videoTrack) videoTrack.stop();
const newStream = await getTargetMediaDevices({ videoDeviceId, audioDeviceId });
useLocalStreamStore.getState().setLocalStream(newStream);
await handleMediaDevices({ videoDeviceId, audioDeviceId });
}
Loading