From 6c8316f050be29cf33e9f5f0ec7c6569375db5b5 Mon Sep 17 00:00:00 2001 From: PortalCube <35104213+PortalCube@users.noreply.github.com> Date: Mon, 6 Nov 2023 13:38:02 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20DevelopPage=20key=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/DevelopPage.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" }, From 5ac6f09d6135f35b522899b0a38165540095c695 Mon Sep 17 00:00:00 2001 From: PortalCube <35104213+PortalCube@users.noreply.github.com> Date: Mon, 6 Nov 2023 13:38:22 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Feat:=20reservation=20create=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reservation/ReservationCreateModal.jsx | 11 +++++++++- src/librarys/api/reservation.js | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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/librarys/api/reservation.js b/src/librarys/api/reservation.js index 428c864..8c8a6ae 100644 --- a/src/librarys/api/reservation.js +++ b/src/librarys/api/reservation.js @@ -10,3 +10,24 @@ export async function getAdminReservationList(id, page = undefined) { const response = await axios.get("/reservation-admin/" + id, { params }); return response.data; } + +export async function createReservation( + admin_id, + user_id, + content, + date, + index, +) { + const axios = getSpringAxios(); + + const data = { + admin_id, + user_id, + content, + date, + index, + }; + console.log(data); + const response = await axios.post("/reservation/", data); + return response.data; +} From a266954c1ba43bd5837209b2ff20b078b28e649b Mon Sep 17 00:00:00 2001 From: PortalCube <35104213+PortalCube@users.noreply.github.com> Date: Mon, 6 Nov 2023 14:13:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Feat:=20WebRTC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 +- src/App.jsx | 5 + .../Reservation/ReservationItem.jsx | 11 +- .../Reservation/ReservationList.jsx | 1 + src/librarys/webrtc/rtc-client.js | 238 ++++++++++++++++++ src/librarys/webrtc/rtc-recorder.js | 59 +++++ src/librarys/webrtc/rtc-signaling.js | 73 ++++++ src/librarys/webrtc/util.js | 25 ++ .../Reservation/ReservationMeetingPage.jsx | 158 ++++++++++++ 9 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 src/librarys/webrtc/rtc-client.js create mode 100644 src/librarys/webrtc/rtc-recorder.js create mode 100644 src/librarys/webrtc/rtc-signaling.js create mode 100644 src/librarys/webrtc/util.js create mode 100644 src/pages/Reservation/ReservationMeetingPage.jsx 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/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) => ( 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/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 ( + + + + + + + + + + + + ); +}; + +export default ReservationMeetingPage; From 446ff8f0ff1b59bae35c8221dfdc9fd24e0c0e67 Mon Sep 17 00:00:00 2001 From: PortalCube <35104213+PortalCube@users.noreply.github.com> Date: Mon, 6 Nov 2023 14:35:58 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Fix:=20type.js=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/librarys/type.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/librarys/type.js b/src/librarys/type.js index 1e2da83..5ab170f 100644 --- a/src/librarys/type.js +++ b/src/librarys/type.js @@ -12,13 +12,13 @@ export const CATEGORY_TYPE = { THIGH: "허벅지", }; -export const ROLE_LIST = Object.entries(ROLE_TYPE).map((key, value) => ({ +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, }), From d11ba88f3f240cbdeb8ae2a0eb27f2b009b90ae7 Mon Sep 17 00:00:00 2001 From: PortalCube <35104213+PortalCube@users.noreply.github.com> Date: Mon, 6 Nov 2023 14:36:25 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Fix:=20=EC=9E=AC=ED=99=9C=EC=B9=98=EB=A3=8C?= =?UTF-8?q?=EC=82=AC=20=EC=98=81=EC=83=81=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EA=B0=80=200=EC=9C=BC=EB=A1=9C=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reducer/video-list.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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: