<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>WebRTC 실시간 모니터링</title>
<style>
.video-wrapper {
position: relative;
width: 100%;
background: #000;
border-radius: 1rem;
overflow: hidden;
aspect-ratio: 16 / 9;
}
@media (max-width: 768px) {
.video-wrapper { aspect-ratio: 9 / 16; }
}
video {
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
}
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
#permissionAlert {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
z-index: 100;
align-items: center;
justify-content: center;
padding: 20px;
}
.copy-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #334155;
color: white;
padding: 8px 16px;
border-radius: 8px;
font-size: 12px;
display: none;
z-index: 200;
}
</style>
</head>
<body class="bg-slate-50 min-h-screen text-slate-900 font-sans">
<div id="permissionAlert">
<div class="bg-white p-6 rounded-2xl max-w-sm w-full text-center shadow-2xl">
<div class="text-red-500 mb-4">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="font-bold text-lg mb-2 text-slate-800">시스템 카메라 권한 거부</h3>
<p class="text-sm text-slate-600 mb-6">운영체제 설정에서 브라우저의 카메라 접근 권한이 허용되어 있는지 확인해 주세요.</p>
<button onclick="location.reload()" class="w-full py-3 bg-slate-900 text-white rounded-xl font-bold">다시 시도</button>
</div>
</div>
<div id="copyToast" class="copy-toast">ID가 클립보드에 복사되었습니다!</div>
<div class="max-w-4xl mx-auto p-4">
<header class="py-8 flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 class="text-2xl font-black tracking-tight text-blue-600">WebRTC LIVE CORE</h1>
<p class="text-sm text-slate-500 font-medium italic">Peer-to-Peer Realtime Streaming</p>
</div>
<div id="connectionPanel" class="flex items-center gap-3 bg-white px-4 py-2 rounded-2xl shadow-sm border border-slate-200 cursor-pointer hover:bg-slate-50 transition-colors" onclick="copyMyId()">
<div id="peerStatusDot" class="status-dot bg-slate-300 transition-colors duration-500"></div>
<div class="flex flex-col">
<span id="myPeerIdDisplay" class="text-[10px] font-mono font-bold text-slate-600 tracking-tighter uppercase leading-none">ID 생성 중...</span>
<span class="text-[8px] text-slate-400 font-bold uppercase mt-1">클릭하여 ID 복사</span>
</div>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="space-y-4">
<div class="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm">
<h2 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">역할 선택</h2>
<div class="grid grid-cols-1 gap-3 mb-6">
<button id="modeSender" onclick="initMode('sender')" class="w-full py-4 rounded-2xl border-2 border-blue-600 bg-blue-50 text-blue-700 font-bold transition-all">📷 송출기 (카메라)</button>
<button id="modeReceiver" onclick="initMode('receiver')" class="w-full py-4 rounded-2xl border-2 border-transparent bg-slate-100 text-slate-500 font-bold transition-all">🖥️ 수신인 (모니터)</button>
</div>
<div id="senderPanel" class="space-y-4">
<label class="block text-[10px] font-bold text-slate-400 uppercase">수신인의 ID 입력</label>
<input type="text" id="remotePeerId" placeholder="상대방의 ID를 입력하세요" class="w-full bg-slate-50 border border-slate-200 rounded-xl p-4 text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none transition-all">
<button id="startBtn" onclick="startBroadcast()" class="w-full py-5 bg-blue-600 text-white rounded-2xl font-black shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95">방송 시작하기</button>
</div>
</div>
<div class="bg-white p-5 rounded-3xl border border-slate-200 text-[11px] leading-relaxed text-slate-500">
<p class="font-bold text-slate-800 mb-1">💡 연결 팁</p>
1. <span class="text-blue-600 font-bold">수신인</span> 기기 상단의 ID를 클릭해 복사합니다.<br>
2. <span class="text-blue-600 font-bold">송출기</span> 기기에 해당 ID를 정확히 붙여넣습니다.<br>
3. 송출기에서 방송을 시작하면 수신기에 화면이 뜹니다.
</div>
</div>
<div class="md:col-span-2 space-y-4">
<div class="video-wrapper shadow-2xl bg-slate-900">
<div id="liveBadge" class="hidden absolute top-4 left-4 z-20 bg-red-600 text-white px-3 py-1 rounded-full text-[10px] font-black animate-pulse flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-white rounded-full"></div> LIVE
</div>
<video id="displayVideo" autoplay playsinline muted></video>
<div id="idleOverlay" class="absolute inset-0 flex flex-col items-center justify-center text-center p-8">
<div class="w-12 h-12 border-4 border-slate-700 border-t-blue-500 rounded-full animate-spin mb-4" id="loadingSpinner"></div>
<p id="idleText" class="text-slate-500 text-sm font-medium">네트워크 연결 확인 중...</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white p-4 rounded-2xl border border-slate-200 text-center">
<p class="text-[10px] text-slate-400 font-bold uppercase mb-1">통신 상태</p>
<p id="connStatus" class="text-sm font-black text-slate-700 font-mono italic">READY</p>
</div>
<div class="bg-white p-4 rounded-2xl border border-slate-200 text-center">
<p class="text-[10px] text-slate-400 font-bold uppercase mb-1">내 역할</p>
<p id="roleStat" class="text-sm font-black text-blue-600 font-mono italic">SENDER</p>
</div>
</div>
</div>
</div>
</div>
<script>
let peer = null;
let myId = null;
let currentMode = 'sender';
let localStream = null;
let reconnectTimeout = null;
let currentCall = null; // 현재 활성화된 호출 객체 추적
const displayVideo = document.getElementById('displayVideo');
const idleOverlay = document.getElementById('idleOverlay');
const loadingSpinner = document.getElementById('loadingSpinner');
const idleText = document.getElementById('idleText');
const liveBadge = document.getElementById('liveBadge');
const myPeerIdDisplay = document.getElementById('myPeerIdDisplay');
const peerStatusDot = document.getElementById('peerStatusDot');
const connStatus = document.getElementById('connStatus');
const permissionAlert = document.getElementById('permissionAlert');
function initPeer() {
if (peer) {
peer.destroy();
}
peer = new Peer({
debug: 1,
config: {
'iceServers': [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
}
});
peer.on('open', (id) => {
myId = id;
myPeerIdDisplay.innerText = `ID: ${id}`;
peerStatusDot.className = 'status-dot bg-green-500 transition-colors duration-500';
loadingSpinner.classList.add('hidden');
idleText.innerText = "연결 준비 완료";
connStatus.innerText = "ONLINE";
});
peer.on('call', (call) => {
console.log('연결 요청 수신됨');
// InvalidStateError 방지: 이미 연결된 호출이 있다면 정리
if (currentCall) {
console.log('기존 연결 종료 후 새 요청 수락');
currentCall.close();
}
currentCall = call;
call.answer(); // 수신 모드에서는 스트림 없이 응답
call.on('stream', (remoteStream) => {
handleIncomingStream(remoteStream);
});
call.on('close', () => {
console.log('호출 종료됨');
stopBroadcast();
});
call.on('error', (err) => {
console.error('Call error:', err);
stopBroadcast();
});
});
peer.on('disconnected', () => {
console.warn('서버와 연결이 끊겼습니다. 재연결 시도 중...');
peerStatusDot.className = 'status-dot bg-yellow-500 transition-colors duration-500';
connStatus.innerText = "RECONNECTING...";
if (reconnectTimeout) clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(() => {
peer.reconnect();
}, 3000);
});
peer.on('error', (err) => {
console.error('PeerJS 오류:', err.type);
peerStatusDot.className = 'status-dot bg-red-500 transition-colors duration-500';
if (err.type === 'peer-unavailable') {
alert('상대방 ID를 찾을 수 없습니다.');
stopBroadcast();
} else if (err.type === 'network' || err.type === 'lost-connection') {
connStatus.innerText = "NETWORK ERROR";
if (reconnectTimeout) clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(initPeer, 5000);
} else if (err.type === 'webrtc') {
console.warn('WebRTC 상태 오류 무시 (중복 응답 등)');
} else {
connStatus.innerText = "ERROR: " + err.type;
}
});
}
function copyMyId() {
if (!myId) return;
const tempInput = document.createElement("input");
tempInput.value = myId;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand("copy");
document.body.removeChild(tempInput);
const toast = document.getElementById('copyToast');
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 2000);
}
function initMode(mode) {
currentMode = mode;
const senderBtn = document.getElementById('modeSender');
const receiverBtn = document.getElementById('modeReceiver');
const senderPanel = document.getElementById('senderPanel');
const roleStat = document.getElementById('roleStat');
if (mode === 'sender') {
senderBtn.className = "w-full py-4 rounded-2xl border-2 border-blue-600 bg-blue-50 text-blue-700 font-bold transition-all";
receiverBtn.className = "w-full py-4 rounded-2xl border-2 border-transparent bg-slate-100 text-slate-500 font-bold transition-all";
senderPanel.classList.remove('hidden');
roleStat.innerText = "SENDER";
} else {
receiverBtn.className = "w-full py-4 rounded-2xl border-2 border-blue-600 bg-blue-50 text-blue-700 font-bold transition-all";
senderBtn.className = "w-full py-4 rounded-2xl border-2 border-transparent bg-slate-100 text-slate-500 font-bold transition-all";
senderPanel.classList.add('hidden');
roleStat.innerText = "RECEIVER";
}
stopBroadcast();
}
async function startBroadcast() {
const targetId = document.getElementById('remotePeerId').value.trim();
if (!targetId) {
alert('수신인 ID를 입력하세요.');
return;
}
try {
permissionAlert.style.display = 'none';
connStatus.innerText = "CAMERA...";
localStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
});
displayVideo.srcObject = localStream;
displayVideo.play().catch(e => console.error("Play error:", e));
idleOverlay.classList.add('hidden');
liveBadge.classList.remove('hidden');
connStatus.innerText = "STREAMING";
if (currentCall) currentCall.close();
currentCall = peer.call(targetId, localStream);
currentCall.on('error', (err) => {
console.error('Call error:', err);
stopBroadcast();
});
} catch (err) {
if (err.name === 'NotAllowedError' || err.message.includes('denied')) {
permissionAlert.style.display = 'flex';
} else {
alert('카메라 시작 실패: ' + err.message);
}
connStatus.innerText = "MEDIA ERROR";
}
}
function handleIncomingStream(stream) {
console.log('스트림 수신 중...');
displayVideo.srcObject = stream;
displayVideo.muted = true;
displayVideo.playsInline = true;
displayVideo.play().then(() => {
idleOverlay.classList.add('hidden');
liveBadge.classList.remove('hidden');
connStatus.innerText = "RECEIVING";
connStatus.classList.add('text-green-600');
}).catch(err => {
console.error("비디오 재생 실패:", err);
idleText.innerText = "화면을 클릭하여 재생하세요";
loadingSpinner.classList.add('hidden');
idleOverlay.onclick = () => {
displayVideo.play();
idleOverlay.classList.add('hidden');
liveBadge.classList.remove('hidden');
};
});
}
function stopBroadcast() {
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream = null;
}
if (currentCall) {
currentCall.close();
currentCall = null;
}
displayVideo.srcObject = null;
idleOverlay.classList.remove('hidden');
idleOverlay.onclick = null;
idleText.innerText = "연결 준비 완료";
liveBadge.classList.add('hidden');
connStatus.innerText = "READY";
connStatus.classList.remove('text-blue-600', 'text-green-600');
}
window.onload = initPeer;
</script>
</body>
</html>