diff --git a/.env b/.env
index 0c14405..1cb7be0 100644
--- a/.env
+++ b/.env
@@ -5,4 +5,7 @@
VITE_SPRING_URL=https://example.com/api/
# AI 서비스의 URL을 입력합니다.
-VITE_AI_URL=https://example.com/ai/
\ No newline at end of file
+VITE_AI_URL=https://example.com/ai/
+
+# 시그널링 서비스의 URL을 입력합니다.
+VITE_SIGNALING_SERVICE_URL=ws://example.com/socket/
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
index 5d74f5d..8648dfb 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -19,6 +19,7 @@ import styled from "styled-components";
import "./App.scss";
import { ReducerContext } from "./reducer/context.js";
import ReservationListPage from "./pages/Reservation/ReservationListPage.jsx";
+import ReservationMeetingPage from "./pages/Reservation/ReservationMeetingPage.jsx";
const Container = styled.div`
margin-top: 60px;
@@ -61,6 +62,10 @@ function App() {
/>
} />
} />
+ }
+ />
diff --git a/src/components/Reservation/ReservationCreateModal.jsx b/src/components/Reservation/ReservationCreateModal.jsx
index 32d666c..b936421 100644
--- a/src/components/Reservation/ReservationCreateModal.jsx
+++ b/src/components/Reservation/ReservationCreateModal.jsx
@@ -13,6 +13,7 @@ import {
reserveCreateReducer,
} from "../../reducer/reservation-create.js";
import dayjs from "dayjs";
+import { createReservation } from "../../librarys/api/reservation.js";
const Container = styled.div`
display: flex;
@@ -69,6 +70,7 @@ export const ReservationCreateModal = () => {
reserveCreateReducer,
intialReserveCreateState,
);
+
const [times, setTimes] = useState(createTimes());
const { index, available, description } = state;
@@ -93,7 +95,14 @@ export const ReservationCreateModal = () => {
});
}
- function onComplete() {
+ async function onComplete() {
+ // const res = await createReservation(
+ // state.adminId,
+ // "ldh",
+ // state.description,
+ // [state.year, state.month + 1, state.date].join("-"),
+ // state.index,
+ // );
console.log(state);
}
diff --git a/src/components/Reservation/ReservationItem.jsx b/src/components/Reservation/ReservationItem.jsx
index c0c4d01..4f1248a 100644
--- a/src/components/Reservation/ReservationItem.jsx
+++ b/src/components/Reservation/ReservationItem.jsx
@@ -13,6 +13,7 @@ import dayjs from "dayjs";
import classNames from "classnames";
import { useDispatch } from "react-redux";
import { show } from "../../redux/modalSlice.js";
+import { useNavigate } from "react-router-dom";
const Container = styled.div`
height: 110px;
@@ -94,8 +95,9 @@ const dummyText = `그러나 한 시와 강아지, 가을 보고, 새워 까닭
const notReadyText = `아직 비대면 진료 요약이 생성되지 않았습니다.`;
-const ReservationItem = ({ name, role, dept, date, index }) => {
+const ReservationItem = ({ id, name, role, dept, date, index }) => {
const dispatch = useDispatch();
+ const navigate = useNavigate();
const image = useMemo(() => {
switch (role) {
@@ -130,7 +132,11 @@ const ReservationItem = ({ name, role, dept, date, index }) => {
if (isDone) {
return 종료되었습니다;
} else if (isOpen) {
- return 입장;
+ return (
+ navigate("/untact/meeting/" + id)}>
+ 입장
+
+ );
} else {
return 예약 시간이 아닙니다;
}
@@ -186,6 +192,7 @@ const ReservationItem = ({ name, role, dept, date, index }) => {
};
ReservationItem.propTypes = {
+ id: PropTypes.string,
name: PropTypes.string,
role: PropTypes.string,
date: PropTypes.string,
diff --git a/src/components/Reservation/ReservationList.jsx b/src/components/Reservation/ReservationList.jsx
index e215c13..8cbec9f 100644
--- a/src/components/Reservation/ReservationList.jsx
+++ b/src/components/Reservation/ReservationList.jsx
@@ -59,6 +59,7 @@ const ReservationList = () => {
{list.map((item) => (
({
+export const ROLE_LIST = Object.entries(ROLE_TYPE).map((key, value) => [
key,
value,
-}));
+]);
export const CATEGORY_LIST = Object.entries(CATEGORY_TYPE).map(
- (key, value) => ({
+ ([key, value]) => ({
key,
value,
}),
diff --git a/src/librarys/webrtc/rtc-client.js b/src/librarys/webrtc/rtc-client.js
new file mode 100644
index 0000000..9ec539f
--- /dev/null
+++ b/src/librarys/webrtc/rtc-client.js
@@ -0,0 +1,238 @@
+import RTCSignalingClient from "./rtc-signaling.js";
+import { registerEvents } from "./util.js";
+
+const CONFIG = {
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
+};
+
+export class RTCClient extends EventTarget {
+ id = null;
+ role = null;
+
+ /** @type {RTCSignalingClient} */
+ signaling = null;
+
+ /** @type {RTCPeerConnection} */
+ peer = null;
+
+ /** @type {RTCDataChannel} */
+ dataChannel = null;
+
+ /** @type {MediaStream} */
+ clientStream = null;
+
+ /** @type {MediaStream} */
+ remoteStream = null;
+
+ get readyState() {
+ return this.peer.connectionState;
+ }
+
+ constructor() {
+ super();
+
+ const signalingEvents = {
+ offer: this._onOffer,
+ answer: this._onAnswer,
+ candidate: this._onCandidate,
+ disconnect: this._onDisconnect,
+ };
+
+ this.signaling = new RTCSignalingClient();
+ registerEvents(this.signaling, signalingEvents, this);
+
+ this.setRTCPeer();
+ }
+
+ async connect(id, role, stream) {
+ if (this.signaling.readyState) {
+ return;
+ }
+ this.id = id;
+ this.role = role;
+ this.setClientStream(stream);
+ await this.signaling.connect(this.id);
+ await this.call();
+ }
+
+ disconnect() {
+ if (this.dataChannel) {
+ this.dataChannel.close();
+ this.dataChannel = null;
+ }
+
+ this.peer.close();
+ this.signaling.send("disconnect", {});
+
+ this.setRTCPeer();
+ this.setClientStream(this.clientStream);
+ this.remoteStream = null;
+
+ this.dispatchEvent(new CustomEvent("disconnect"));
+ }
+
+ destory() {
+ if (this.dataChannel) {
+ this.dataChannel.close();
+ this.dataChannel = null;
+ }
+
+ this.peer.close();
+ this.signaling.send("disconnect", {});
+ this.signaling.disconnect();
+
+ this.clientStream.getTracks().forEach((track) => track.stop());
+
+ this.clientStream = null;
+ this.remoteStream = null;
+
+ this.dispatchEvent(new CustomEvent("disconnect"));
+ }
+
+ async call() {
+ if (!this.signaling.readyState) {
+ return;
+ }
+
+ this.log("Data channel을 만듭니다.");
+ this.setDataChannel(this.peer.createDataChannel("default"));
+
+ this.log("Offer를 보냅니다.");
+ const offer = await this.peer.createOffer();
+ await this.peer.setLocalDescription(offer);
+
+ this.signaling.send("offer", offer);
+ }
+
+ async answer() {
+ if (!this.signaling.readyState) {
+ return;
+ }
+
+ this.log("Answer를 보냅니다.");
+
+ const answer = await this.peer.createAnswer();
+ await this.peer.setLocalDescription(answer);
+
+ this.signaling.send("answer", answer);
+ }
+
+ sendMessage(message) {
+ if (this.dataChannel && this.dataChannel.readyState === "open") {
+ this.dataChannel.send(message);
+ } else {
+ throw new Error("[RTCClient] 아직 연결이 열리지 않았습니다.");
+ }
+ }
+
+ setRTCPeer() {
+ const peerEvents = {
+ connectionstatechange: this._onConnectionStateChange,
+ icecandidate: this._onIceCandidate,
+ datachannel: this._onDataChannel,
+ track: this._onTrack,
+ };
+
+ this.peer = new RTCPeerConnection(CONFIG);
+ registerEvents(this.peer, peerEvents, this);
+ }
+
+ async setRemoteDescription(payload) {
+ const remoteDescription = new RTCSessionDescription(payload);
+ await this.peer.setRemoteDescription(remoteDescription);
+ }
+
+ setDataChannel(channel) {
+ this.dataChannel = channel;
+ this.dataChannel.addEventListener("open", (event) => {
+ this.log("data open", event);
+ });
+ this.dataChannel.addEventListener("message", (event) => {
+ this.log("data message", event);
+ });
+ }
+
+ setClientStream(stream) {
+ this.clientStream = stream;
+ this.clientStream.getTracks().forEach((track) => {
+ this.peer.addTrack(track, this.clientStream);
+ });
+ }
+
+ // Peer Events
+ _onIceCandidate(event) {
+ const candidate = event.candidate;
+ if (candidate) {
+ this.log("Candidate 정보를 전달합니다.");
+ this.signaling.send("candidate", candidate);
+ }
+ }
+
+ _onConnectionStateChange(event) {
+ this.log("연결 상태가 변경되었습니다:", this.peer.connectionState);
+ if (["disconnected", "failed"].includes(this.peer.connectionState)) {
+ this.disconnect();
+ } else if (this.peer.connectionState === "connected") {
+ this.dispatchEvent(new CustomEvent("open"));
+ }
+ }
+
+ _onDataChannel(event) {
+ if (event.channel) {
+ this.log("Channel 데이터를 받았습니다:", event.channel);
+ this.setDataChannel(event.channel);
+ this.dispatchEvent(new CustomEvent("channelopen"));
+ }
+ }
+
+ _onTrack(event) {
+ if (event.streams) {
+ this.log("MediaStream을 받았습니다:", event.streams);
+ this.remoteStream = event.streams[0];
+ this.dispatchEvent(
+ new CustomEvent("stream", { detail: this.remoteStream }),
+ );
+ }
+ }
+
+ // Data Channel Events
+ _onChannelOpen(event) {}
+ _onChannelMessage(event) {}
+
+ // Signaling Events
+ async _onOffer({ detail: payload }) {
+ this.log("Offer 정보를 받았습니다.");
+ // Remote Description 지정
+ await this.setRemoteDescription(payload);
+ await this.answer();
+ }
+
+ async _onAnswer({ detail: payload }) {
+ this.log("Answer 정보를 받았습니다.");
+ // Remote Description 지정
+ await this.setRemoteDescription(payload);
+ }
+
+ async _onCandidate({ detail: payload }) {
+ this.log("Candidate 정보를 받았습니다.", event);
+ try {
+ const candidate = new RTCIceCandidate(payload);
+ await this.peer.addIceCandidate(candidate);
+ } catch (e) {
+ this.logError("ice candidate를 받는데 실패했습니다.", e);
+ }
+ }
+
+ _onDisconnect({ detail: payload }) {
+ this.log("상대가 연결을 종료했습니다.");
+ this.disconnect();
+ }
+
+ log(...arg) {
+ console.log("[RTCClient]", ...arg);
+ }
+
+ logError(...arg) {
+ console.error("[RTCClient]", ...arg);
+ }
+}
diff --git a/src/librarys/webrtc/rtc-recorder.js b/src/librarys/webrtc/rtc-recorder.js
new file mode 100644
index 0000000..b40e6df
--- /dev/null
+++ b/src/librarys/webrtc/rtc-recorder.js
@@ -0,0 +1,59 @@
+import { getSampleRate } from "./util.js";
+
+export class AudioRecorder extends EventTarget {
+ /** @type {MediaRecorder} */
+ instance = null;
+
+ /** @type {Blob[]} */
+ chunks = null;
+
+ start(stream) {
+ if (this.instance) {
+ this.instance.stop();
+ this.instance = null;
+ }
+
+ this.instance = new MediaRecorder(stream, {
+ mimeType: "audio/webm",
+ });
+
+ this.chunks = [];
+
+ this.instance.addEventListener("dataavailable", async (event) => {
+ this.dispatchEvent(new CustomEvent("data", { detail: event.data }));
+
+ if (event.data.size === 0) {
+ return;
+ }
+
+ this.chunks.push(event.data);
+
+ if (this.instance.state == "inactive") {
+ const blob = new Blob(this.chunks, {
+ type: "audio/webm",
+ });
+
+ const sampleRate = await getSampleRate(blob);
+ this.dispatchEvent(
+ new CustomEvent("complete", {
+ detail: {
+ data: blob,
+ sampleRate,
+ },
+ }),
+ );
+ }
+ });
+
+ this.instance.start(3000);
+ }
+
+ stop() {
+ if (!this.instance || this.instance.state != "recording") {
+ return;
+ }
+
+ this.instance.stop();
+ this.instance.stream.getTracks().forEach((track) => track.stop());
+ }
+}
diff --git a/src/librarys/webrtc/rtc-signaling.js b/src/librarys/webrtc/rtc-signaling.js
new file mode 100644
index 0000000..a1bbfc8
--- /dev/null
+++ b/src/librarys/webrtc/rtc-signaling.js
@@ -0,0 +1,73 @@
+const URL = import.meta.env.VITE_SIGNALING_SERVICE_URL;
+
+export default class RTCSignalingClient extends EventTarget {
+ /** @type {WebSocket} */
+ instance = null;
+
+ get readyState() {
+ if (this.instance === null) {
+ return false;
+ } else {
+ return this.instance.readyState === 1;
+ }
+ }
+
+ constructor() {
+ super();
+ }
+
+ log(...arg) {
+ console.log("[RTCWebSocket]", ...arg);
+ }
+
+ logError(...arg) {
+ console.error("[RTCWebSocket]", ...arg);
+ }
+
+ connect(id) {
+ return new Promise((resolve, reject) => {
+ this.instance = new WebSocket(URL + id);
+
+ this.instance.addEventListener("open", () => {
+ this.log("접속 완료.");
+ resolve();
+ });
+
+ this.instance.addEventListener("error", (event) => {
+ this.logError("에러:", event);
+ reject(event);
+ });
+
+ // 메세지를 받으면, type으로 RTCWebSocket 이벤트 Emit
+ this.instance.addEventListener("message", ({ data }) => {
+ const message = JSON.parse(data);
+ this.dispatchEvent(
+ new CustomEvent(message.type, { detail: message.payload }),
+ );
+ });
+ });
+ }
+
+ disconnect() {
+ this.instance.close();
+ this.instance = null;
+ }
+
+ send(type, payload) {
+ const message = JSON.stringify({
+ type,
+ payload,
+ });
+
+ this.instance.send(message);
+ }
+
+ addEventListener(type, listener) {
+ const socketEvents = ["open", "close", "message", "error"];
+ if (socketEvents.includes(type)) {
+ this.instance.addEventListener(type, listener); // WebSocket 이벤트 등록
+ } else {
+ super.addEventListener(type, listener); // RTCWebSocket 이벤트 등록
+ }
+ }
+}
diff --git a/src/librarys/webrtc/util.js b/src/librarys/webrtc/util.js
new file mode 100644
index 0000000..4eb40f0
--- /dev/null
+++ b/src/librarys/webrtc/util.js
@@ -0,0 +1,25 @@
+export function registerEvents(eventTarget, eventList, thisArg = this) {
+ Object.entries(eventList).forEach(([event, listener]) => {
+ eventTarget.addEventListener(event, listener.bind(thisArg));
+ });
+}
+
+function blobToArrayBuffer(blob) {
+ return new Promise((resolve) => {
+ const fileReader = new FileReader();
+
+ fileReader.onload = function () {
+ resolve(fileReader.result);
+ };
+
+ fileReader.readAsArrayBuffer(blob);
+ });
+}
+
+export async function getSampleRate(blob) {
+ const audioContext = new AudioContext();
+ const arrayBuffer = await blobToArrayBuffer(blob);
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ return audioBuffer.sampleRate;
+}
diff --git a/src/pages/DevelopPage.jsx b/src/pages/DevelopPage.jsx
index 04e5309..4e65bc8 100644
--- a/src/pages/DevelopPage.jsx
+++ b/src/pages/DevelopPage.jsx
@@ -37,15 +37,15 @@ const DevelopPage = () => {
{ path: "/signup", name: "017SignupPage" },
{ path: "/", name: "DevelopPage" },
{ path: "/userdash", name: "001MyUserPage" },
- { path: "/untact/list", name: "004UserUntactReservePage" },
+ { path: "/untact/list?q=1", name: "004UserUntactReservePage" },
{ path: "/userreserve", name: "005UserReservePage" },
{ path: "/doctordash", name: "009DoctorDashBoardPage" },
{ path: "/doctorchart", name: "010DoctorChartPage" },
{ path: "/doctordetail", name: "013DoctorDetailPage" },
{ path: "/doctorpatientlist", name: "012DoctorPatientListPage" },
- { path: "/untact/list", name: "014DoctorUntactReservePage" },
+ { path: "/untact/list?q=2", name: "014DoctorUntactReservePage" },
{ path: "/theradashboard", name: "018TheraDashBoardPage" },
- { path: "/untact/list", name: "025TheraUntactReservePage" },
+ { path: "/untact/list?q=3", name: "025TheraUntactReservePage" },
{ path: "/therapatientlist", name: "027TheraPatientListPage" },
{ path: "/theradetail", name: "028TheraDetailPage" },
{ path: "/theraexerciselist", name: "019TheraExerciseListPage" },
diff --git a/src/pages/Reservation/ReservationMeetingPage.jsx b/src/pages/Reservation/ReservationMeetingPage.jsx
new file mode 100644
index 0000000..be053e0
--- /dev/null
+++ b/src/pages/Reservation/ReservationMeetingPage.jsx
@@ -0,0 +1,158 @@
+import styled from "styled-components";
+
+import { useEffect, useRef, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { RTCClient } from "../../librarys/webrtc/rtc-client.js";
+import { AudioRecorder } from "../../librarys/webrtc/rtc-recorder.js";
+import dayjs from "dayjs";
+
+const Container = styled.div`
+ height: 100%;
+`;
+
+const VideoContainer = styled.div`
+ width: 100vw;
+ height: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+`;
+
+const VideoWrapper = styled.div`
+ width: 50vw;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const Video = styled.video`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ background-color: #1f1f1f;
+`;
+
+const Description = styled.p`
+ margin-top: -64px;
+ font-size: 24px;
+ text-align: center;
+ color: white;
+ text-shadow: 0px 2px 2px #0000007f;
+`;
+
+const Status = styled.p`
+ top: 50%;
+ position: absolute;
+ transform: translateY(-50%);
+ color: white;
+ text-align: center;
+ font-size: 3.5vw;
+ font-weight: 600;
+`;
+
+const Menu = styled.div`
+ bottom: 64px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 16px;
+ font-size: 16px;
+ background-color: #0000003f;
+ color: white;
+ border-radius: 256px;
+ position: absolute;
+ display: flex;
+ gap: 16px;
+`;
+
+const Button = styled.button`
+ padding: 2px 16px;
+ border-radius: 256px;
+ background: none;
+ border: none;
+ cursor: pointer;
+
+ &:hover {
+ background-color: #0000001f;
+ }
+`;
+
+const ReservationMeetingPage = () => {
+ const navigate = useNavigate();
+ const clientVideo = useRef(null);
+ const remoteVideo = useRef(null);
+ const { uuid } = useParams();
+ const [remoteStatus, setRemoteStatus] = useState("연결되지 않음");
+ const [peer, setPeer] = useState(new RTCClient());
+ const [recorder, setRecorder] = useState(new AudioRecorder());
+
+ useEffect(() => {
+ const unload = () => {
+ peer.destory();
+ };
+
+ peer.addEventListener("stream", onStream);
+ peer.addEventListener("disconnect", () => {
+ recorder.stop();
+ setRemoteStatus("연결 종료");
+ });
+ peer.addEventListener("open", () => setRemoteStatus(""));
+
+ recorder.addEventListener("complete", (event) => {
+ const blob = event.detail.data;
+ const sampleRate = event.detail.sampleRate;
+ });
+
+ window.addEventListener("beforeunload", unload);
+
+ (async () => {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: true,
+ });
+
+ peer.connect(uuid, "user", stream);
+
+ clientVideo.current.volume = 0;
+ clientVideo.current.srcObject = stream;
+ })();
+
+ return () => {
+ peer.destory();
+ window.removeEventListener("beforeunload", unload);
+ };
+ }, []);
+
+ async function onStream(event) {
+ const stream = event.detail;
+
+ const audioStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+
+ recorder.start(audioStream);
+ remoteVideo.current.srcObject = stream;
+ }
+
+ return (
+
+
+
+
+ 클라이언트
+
+
+
+ {remoteStatus}
+ 상대방
+
+
+
+
+ );
+};
+
+export default ReservationMeetingPage;
diff --git a/src/reducer/video-list.js b/src/reducer/video-list.js
index e4c1816..2c5d92c 100644
--- a/src/reducer/video-list.js
+++ b/src/reducer/video-list.js
@@ -21,13 +21,13 @@ export function videoListReducer(state, action) {
case "page":
return {
...state,
- page: action.payload,
+ page: action.payload || 1,
};
case "data":
return {
...state,
- list: action.payload.dtoList,
- page: action.payload.page,
+ list: action.payload.dtoList || [],
+ page: action.payload.page || 1,
totalPage: action.payload.end,
};
default: