mirror of
https://git.hmsn.ink/call/client.git
synced 2026-03-19 15:54:58 +09:00
first
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
*.iml
|
||||||
18
call.html
Normal file
18
call.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Title</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="webrtc-controls">
|
||||||
|
<input type="text" id="peer-id-input" placeholder="상대방 ID 입력">
|
||||||
|
<button id="call-btn">전화걸기</button>
|
||||||
|
<button id="hangup-btn">끊기</button>
|
||||||
|
<button id="record-btn">녹음시작</button>
|
||||||
|
<button id="stop-record-btn">녹음중지</button>
|
||||||
|
<audio id="audio-playback" controls></audio>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</html>
|
||||||
23
src/main.js
Normal file
23
src/main.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// main.js — 기존 웹 페이지에서 <script type="module"> 로 로드
|
||||||
|
|
||||||
|
import { WebRTCManager } from '/client/src/webrtc/webrtcManager.js';
|
||||||
|
import { AudioRecorder } from '/client/src/webrtc/audioRecorder.js';
|
||||||
|
import { UIController } from '/client/src/webrtc/uiController.js';
|
||||||
|
|
||||||
|
// DOM 요소 연결 (기존 웹에 있는 버튼/요소 선택)
|
||||||
|
const callButton = document.getElementById('call-btn');
|
||||||
|
const hangupButton = document.getElementById('hangup-btn');
|
||||||
|
const recordButton = document.getElementById('record-btn');
|
||||||
|
const stopRecordButton = document.getElementById('stop-record-btn');
|
||||||
|
const audioPlayback = document.getElementById('audio-playback');
|
||||||
|
|
||||||
|
// 인스턴스 생성
|
||||||
|
const rtcManager = new WebRTCManager('http://localhost:3001');
|
||||||
|
const recorder = new AudioRecorder();
|
||||||
|
const ui = new UIController({ callButton, hangupButton, recordButton, stopRecordButton, audioPlayback });
|
||||||
|
|
||||||
|
// UI 이벤트 바인딩
|
||||||
|
ui.bindEvents(rtcManager, recorder);
|
||||||
|
|
||||||
|
// 필요한 경우 초기화
|
||||||
|
rtcManager.init();
|
||||||
43
src/webrtc/audioRecorder.js
Normal file
43
src/webrtc/audioRecorder.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// audioRecorder.js
|
||||||
|
|
||||||
|
export class AudioRecorder {
|
||||||
|
constructor() {
|
||||||
|
this.mediaRecorder = null;
|
||||||
|
this.audioChunks = [];
|
||||||
|
this.onRecordingComplete = null; // 외부 콜백
|
||||||
|
}
|
||||||
|
|
||||||
|
start(stream) {
|
||||||
|
this.audioChunks = [];
|
||||||
|
const options = { mimeType: 'audio/webm;codecs=opus' };
|
||||||
|
this.mediaRecorder = new MediaRecorder(stream, options);
|
||||||
|
|
||||||
|
this.mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
this.audioChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||||
|
const audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
if (this.onRecordingComplete) {
|
||||||
|
this.onRecordingComplete(audioUrl, audioBlob);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder.start();
|
||||||
|
console.log('⏺️ Recording started');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||||
|
this.mediaRecorder.stop();
|
||||||
|
console.log('⏹️ Recording stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording() {
|
||||||
|
return this.mediaRecorder?.state === 'recording';
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/webrtc/uiController.js
Normal file
52
src/webrtc/uiController.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// uiController.js (수정본)
|
||||||
|
|
||||||
|
export class UIController {
|
||||||
|
constructor({ callButton, hangupButton, recordButton, stopRecordButton, audioPlayback, peerIdInput }) {
|
||||||
|
this.callButton = callButton;
|
||||||
|
this.hangupButton = hangupButton;
|
||||||
|
this.recordButton = recordButton;
|
||||||
|
this.stopRecordButton = stopRecordButton;
|
||||||
|
this.audioPlayback = audioPlayback;
|
||||||
|
this.peerIdInput = peerIdInput; // ✅ 상대방 ID 입력 필드
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents(rtcManager, recorder) {
|
||||||
|
// 통화 시작
|
||||||
|
this.callButton.onclick = () => {
|
||||||
|
const peerId = this.peerIdInput.value.trim();
|
||||||
|
if (!peerId) return alert('상대방 ID를 입력하세요');
|
||||||
|
rtcManager.createOffer(peerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통화 종료
|
||||||
|
this.hangupButton.onclick = () => rtcManager.hangup();
|
||||||
|
|
||||||
|
// 녹음 시작
|
||||||
|
this.recordButton.onclick = () => {
|
||||||
|
const stream = rtcManager.remoteStream || rtcManager.localStream;
|
||||||
|
if (!stream) return alert('스트림이 없습니다');
|
||||||
|
recorder.start(stream);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 녹음 중지
|
||||||
|
this.stopRecordButton.onclick = () => {
|
||||||
|
recorder.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 녹음 완료 시 재생
|
||||||
|
recorder.onRecordingComplete = (audioUrl, audioBlob) => {
|
||||||
|
this.audioPlayback.src = audioUrl;
|
||||||
|
this.audioPlayback.play();
|
||||||
|
|
||||||
|
// 필요시 서버 업로드
|
||||||
|
// uploadRecording(audioBlob);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 원격 스트림 수신 시 자동 재생
|
||||||
|
rtcManager.onRemoteStream = (stream) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.srcObject = stream;
|
||||||
|
audio.play().catch(e => console.warn('자동 재생 실패:', e));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/webrtc/webrtcManager.js
Normal file
126
src/webrtc/webrtcManager.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// webrtcManager.js (수정본 — STUN 없이 ICE만)
|
||||||
|
|
||||||
|
export class WebRTCManager {
|
||||||
|
constructor(signalUrl) {
|
||||||
|
this.pc = null;
|
||||||
|
this.localStream = null;
|
||||||
|
this.remoteStream = null;
|
||||||
|
this.onRemoteStream = null;
|
||||||
|
this.signalUrl = signalUrl; // 시그널링 서버 주소
|
||||||
|
this.ws = null;
|
||||||
|
this.myId = null;
|
||||||
|
this.peerId = null; // 상대방 ID (수동 입력 또는 자동 할당)
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.requestMicPermission()
|
||||||
|
this.setupSignaling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestMicPermission() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||||
|
console.log('✅ 마이크 접근 성공');
|
||||||
|
stream.getTracks().forEach(track => track.stop()); // 임시로 열었다가 닫음
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 마이크 권한 거부 또는 장치 없음:', err.message);
|
||||||
|
alert('마이크 접근이 필요합니다. 브라우저 설정에서 마이크 권한을 허용해주세요.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSignaling() {
|
||||||
|
this.ws = new WebSocket(this.signalUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('🔌 시그널링 서버 연결됨');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'your-id') {
|
||||||
|
this.myId = msg.id;
|
||||||
|
console.log('내 ID:', this.myId);
|
||||||
|
} else if (msg.type === 'offer') {
|
||||||
|
this.setRemoteDescription(msg.sdp);
|
||||||
|
this.addIceCandidateQueue.forEach(c => this.pc.addIceCandidate(c));
|
||||||
|
this.addIceCandidateQueue = [];
|
||||||
|
} else if (msg.type === 'answer') {
|
||||||
|
this.setRemoteDescription(msg.sdp);
|
||||||
|
} else if (msg.type === 'candidate') {
|
||||||
|
this.addIceCandidate(msg.candidate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createPeerConnection() {
|
||||||
|
// ✅ STUN 서버 없이 빈 설정 — 인트라넷 내부 IP로 직접 연결
|
||||||
|
this.pc = new RTCPeerConnection({});
|
||||||
|
|
||||||
|
this.localStream.getTracks().forEach(track => {
|
||||||
|
this.pc.addTrack(track, this.localStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pc.ontrack = (event) => {
|
||||||
|
this.remoteStream = event.streams[0];
|
||||||
|
if (this.onRemoteStream) this.onRemoteStream(this.remoteStream);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE Candidate → 시그널링으로 전송
|
||||||
|
this.pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
console.log('📤 ICE Candidate:', event.candidate.candidate);
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
target: this.peerId,
|
||||||
|
type: 'candidate',
|
||||||
|
candidate: event.candidate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOffer(targetId) {
|
||||||
|
this.peerId = targetId;
|
||||||
|
this.createPeerConnection();
|
||||||
|
const offer = await this.pc.createOffer();
|
||||||
|
await this.pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
target: targetId,
|
||||||
|
type: 'offer',
|
||||||
|
sdp: offer
|
||||||
|
}));
|
||||||
|
console.log('📤 Offer 전송 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRemoteDescription(sdp) {
|
||||||
|
await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
if (sdp.type === 'offer') {
|
||||||
|
const answer = await this.pc.createAnswer();
|
||||||
|
await this.pc.setLocalDescription(answer);
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
target: this.peerId || 'unknown',
|
||||||
|
type: 'answer',
|
||||||
|
sdp: answer
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIceCandidate(candidate) {
|
||||||
|
if (this.pc && this.pc.remoteDescription) {
|
||||||
|
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
} else {
|
||||||
|
// SDP 설정 전이라면 큐에 저장
|
||||||
|
this.addIceCandidateQueue = this.addIceCandidateQueue || [];
|
||||||
|
this.addIceCandidateQueue.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hangup() {
|
||||||
|
this.pc?.close();
|
||||||
|
this.localStream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.ws?.close();
|
||||||
|
console.log('📞 통화 종료');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user