Skip to content
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
13 changes: 13 additions & 0 deletions backend/src/socket/socket.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,17 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
candidateSendID: data.candidateSendID,
});
}

@SubscribeMessage("reaction")
handleReaction(
socket: any,
data: {
roomId: string;
reaction: string;
}
) {
this.server
.to(data.roomId)
.emit("reaction", { senderId: socket.id, reaction: data.reaction });
}
}
32 changes: 32 additions & 0 deletions frontend/src/components/session/DisplayMediaStream.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";

interface DisplayMediaStreamProps {
mediaStream: MediaStream | null;
isLocal: boolean;
}
const DisplayMediaStream = ({
mediaStream,
isLocal,
}: DisplayMediaStreamProps) => {
const videoRef = useRef<HTMLVideoElement | null>(null);

useEffect(() => {
if (mediaStream !== null && videoRef.current) {
videoRef.current.srcObject = mediaStream;
} else if (videoRef.current && mediaStream === null) {
videoRef.current.srcObject = null;
}
}, [mediaStream]);

return (
<video
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="w-full"
/>
);
};

export default DisplayMediaStream;
3 changes: 3 additions & 0 deletions frontend/src/components/session/SessionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
interface Props {
handleVideoToggle: () => void;
handleMicToggle: () => void;
handleReaction: (reactionType: string) => void;
userVideoDevices: MediaDeviceInfo[];
userAudioDevices: MediaDeviceInfo[];
setSelectedVideoDeviceId: (deviceId: string) => void;
Expand All @@ -20,6 +21,7 @@ interface Props {
const SessionToolbar = ({
handleVideoToggle,
handleMicToggle,
handleReaction,
userVideoDevices,
userAudioDevices,
setSelectedVideoDeviceId,
Expand Down Expand Up @@ -55,6 +57,7 @@ const SessionToolbar = ({
{isMicOn ? <BsMic /> : <BsMicMute />}
</button>
<button
onClick={() => handleReaction("thumbs_up")}
className="h-full aspect-square bg-white text-green-500 border box-border border-accent-gray-50 hover:bg-grayscale-50 p-3 rounded-full"
aria-label={"좋아요"}
>
Expand Down
85 changes: 57 additions & 28 deletions frontend/src/components/session/VideoContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,78 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import {
BsMic,
BsMicMute,
BsCameraVideo,
BsCameraVideoOff,
} from "react-icons/bs";
import DisplayMediaStream from "./DisplayMediaStream.tsx";

interface VideoContainerProps {
nickname: string;
isMicOn: boolean;
isVideoOn: boolean;
isLocal: boolean;
reaction: string;
stream: MediaStream;
}

const VideoContainer = forwardRef(
(
{ nickname, isMicOn, isVideoOn, isLocal }: VideoContainerProps,
ref: React.Ref<HTMLVideoElement>
) => {
({
nickname,
isMicOn,
isVideoOn,
isLocal,
reaction,
stream,
}: VideoContainerProps) => {
const renderReaction = (reaction: string) => {
console.log(reaction);
switch (reaction) {
case "thumbs_up":
return "👍";
case "":
default:
return "";
}
};
return (
<div className="bg-black rounded-2xl overflow-hidden shadow relative flex-grow max-w-4xl">
<video
ref={ref}
autoPlay
playsInline
muted={isLocal}
className="w-full"
/>
<div className="inline-flex gap-4 absolute bottom-2 w-full justify-between px-2">
<p className="bg-grayscale-500 bg-opacity-50 text-white px-2 py-0.5 rounded">
{isLocal && "Me"} {nickname}
</p>
<div className={"inline-flex gap-4 px-2 items-center"}>
{isMicOn ? (
<BsMic className="text-white" />
) : (
<BsMicMute className="text-red-500" />
)}
{isVideoOn ? (
<BsCameraVideo className="text-white" />
) : (
<BsCameraVideoOff className="text-red-500" />
)}
<div className="relative flex-grow max-w-4xl">
<div className="bg-black rounded-2xl overflow-hidden shadow">
<DisplayMediaStream mediaStream={stream} isLocal={isLocal} />
<div className="inline-flex gap-4 absolute bottom-2 w-full justify-between px-2">
<p className="bg-grayscale-500 bg-opacity-50 text-white px-2 py-0.5 rounded">
{isLocal && "Me"} {nickname}
</p>
<div className={"inline-flex gap-4 px-2 items-center"}>
{isMicOn ? (
<BsMic className="text-white" />
) : (
<BsMicMute className="text-red-500" />
)}
{isVideoOn ? (
<BsCameraVideo className="text-white" />
) : (
<BsCameraVideoOff className="text-red-500" />
)}
</div>
</div>
</div>
{
<div
className={`
pointer-events-none
absolute w-12 h-12 text-xl
flex items-center justify-center
top-2 right-2 text-white p-2 rounded-xl
bg-accent-gray bg-opacity-50
transition-all duration-300
animate-fade-in-out
${reaction ? "opacity-100" : "opacity-0"}
`}
>
<span className="animate-bounce">{renderReaction(reaction)}</span>
</div>
}
</div>
);
}
Expand Down
91 changes: 71 additions & 20 deletions frontend/src/pages/SessionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import VideoContainer from "../components/session/VideoContainer.tsx";
import { useNavigate } from "react-router-dom";
import useSocket from "../hooks/useSocket.ts";
Expand All @@ -15,15 +15,18 @@ interface PeerConnection {
peerId: string; // 연결된 상대의 ID
peerNickname: string; // 상대의 닉네임
stream: MediaStream; // 상대방의 비디오/오디오 스트림
reaction?: string;
}

const SessionPage = () => {
const { socket } = useSocket(import.meta.env.VITE_SIGNALING_SERVER_URL);
const [peers, setPeers] = useState<PeerConnection[]>([]); // 연결 관리
const [roomId, setRoomId] = useState<string>("");
const [nickname, setNickname] = useState<string>("");
const [reaction, setReaction] = useState("");
const [isVideoOn, setIsVideoOn] = useState<boolean>(true);
const [isMicOn, setIsMicOn] = useState<boolean>(true);

const {
userVideoDevices,
userAudioDevices,
Expand All @@ -35,9 +38,12 @@ const SessionPage = () => {
stream: myStream,
} = useMediaDevices();

const reactionTimeouts = useRef<{
[key: string]: ReturnType<typeof setTimeout>;
}>({});
const myVideoRef = useRef<HTMLVideoElement | null>(null);
const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({});
const peerVideoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({});
// const peerVideoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({});
const navigate = useNavigate();

// STUN 서버 설정
Expand Down Expand Up @@ -93,6 +99,7 @@ const SessionPage = () => {
socket.off("getAnswer");
socket.off("getCandidate");
socket.off("user_exit");
socket.off("reaction");
}
};
}, [socket]);
Expand Down Expand Up @@ -132,6 +139,15 @@ const SessionPage = () => {
}
};

const handleReaction = (reactionType: string) => {
if (socket) {
socket.emit("reaction", {
roomId: roomId,
reaction: reactionType,
});
}
};

// 방 입장 처리: 사용자가 join room 버튼을 클릭할 때
const joinRoom = async () => {
if (!socket || !roomId || !nickname) return;
Expand Down Expand Up @@ -244,6 +260,38 @@ const SessionPage = () => {
setPeers((prev) => prev.filter((peer) => peer.peerId !== id));
}
});

socket.on(
"reaction",
({ senderId, reaction }: { senderId: string; reaction: string }) => {
if (reactionTimeouts.current[senderId]) {
clearTimeout(reactionTimeouts.current[senderId]);
}

if (senderId === socket.id) {
setReaction(reaction);

reactionTimeouts.current[senderId] = setTimeout(() => {
setReaction("");
delete reactionTimeouts.current[senderId];
}, 3000);
} else {
addReaction(senderId, reaction);
reactionTimeouts.current[senderId] = setTimeout(() => {
addReaction(senderId, "");
delete reactionTimeouts.current[senderId];
}, 3000);
}
}
);
};

const addReaction = (senderId: string, reactionType: string) => {
setPeers((prev) =>
prev.map((peer) =>
peer.peerId === senderId ? { ...peer, reaction: reactionType } : peer
)
);
};

// Peer Connection 생성
Expand Down Expand Up @@ -339,6 +387,8 @@ const SessionPage = () => {
}
};

// 공감 기능 관련

return (
<section className="w-screen h-screen flex flex-col max-w-[1440px]">
<div className="w-screen flex gap-2 mb-4 space-y-2">
Expand Down Expand Up @@ -388,32 +438,33 @@ const SessionPage = () => {
isMicOn={isMicOn}
isVideoOn={isVideoOn}
isLocal={true}
reaction={reaction || ""}
stream={myStream!}
/>
</div>
<div className={"listeners w-full flex gap-2 px-6"}>
{
// 상대방의 비디오 표시
peers.map((peer) => (
<VideoContainer
ref={(el) => {
// 비디오 엘리먼트가 있고, 스트림이 있을 때
if (el && peer.stream) {
el.srcObject = peer.stream;
}
peerVideoRefs.current[peer.peerId] = el;
}}
nickname={peer.peerNickname}
isMicOn={true}
isVideoOn={true}
isLocal={false}
/>
))
}
{useMemo(
() =>
// 상대방의 비디오 표시
peers.map((peer) => (
<VideoContainer
key={peer.peerId}
nickname={peer.peerNickname}
isMicOn={true}
isVideoOn={true}
isLocal={false}
reaction={peer.reaction || ""}
stream={peer.stream}
/>
)),
[peers]
)}
</div>
</div>
<SessionToolbar
handleVideoToggle={handleVideoToggle}
handleMicToggle={handleMicToggle}
handleReaction={handleReaction}
userVideoDevices={userVideoDevices}
userAudioDevices={userAudioDevices}
setSelectedVideoDeviceId={setSelectedVideoDeviceId}
Expand Down