|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Felix Voice Chat</title> |
|
<style> |
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
background: #1a1a2e; |
|
color: #eee; |
|
height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
.container { |
|
text-align: center; |
|
max-width: 400px; |
|
} |
|
.avatar { |
|
font-size: 64px; |
|
margin-bottom: 16px; |
|
} |
|
h1 { |
|
font-size: 28px; |
|
font-weight: 600; |
|
margin-bottom: 8px; |
|
} |
|
.subtitle { |
|
color: #888; |
|
font-size: 14px; |
|
margin-bottom: 40px; |
|
} |
|
#status { |
|
font-size: 15px; |
|
margin-bottom: 32px; |
|
min-height: 24px; |
|
color: #aaa; |
|
} |
|
#status.connected { color: #4ade80; } |
|
#status.connecting { color: #facc15; } |
|
#status.error { color: #f87171; } |
|
|
|
#connect-btn { |
|
width: 80px; |
|
height: 80px; |
|
border-radius: 50%; |
|
border: none; |
|
background: #3b82f6; |
|
color: white; |
|
font-size: 28px; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
margin: 0 auto; |
|
} |
|
#connect-btn:hover { background: #2563eb; transform: scale(1.05); } |
|
#connect-btn.active { |
|
background: #ef4444; |
|
animation: pulse 2s infinite; |
|
} |
|
#connect-btn.active:hover { background: #dc2626; } |
|
|
|
@keyframes pulse { |
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } |
|
50% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); } |
|
} |
|
|
|
.hint { |
|
margin-top: 32px; |
|
font-size: 12px; |
|
color: #555; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="avatar">📋</div> |
|
<h1>Felix</h1> |
|
<p class="subtitle">Voice Chat</p> |
|
<p id="status">Ready</p> |
|
<button id="connect-btn">🎙️</button> |
|
<p class="hint">Click to connect · Allow microphone access</p> |
|
</div> |
|
|
|
<audio id="audio-el" autoplay></audio> |
|
|
|
<script> |
|
const statusEl = document.getElementById("status") |
|
const buttonEl = document.getElementById("connect-btn") |
|
const audioEl = document.getElementById("audio-el") |
|
|
|
let connected = false |
|
let peerConnection = null |
|
|
|
const sendIceCandidate = async (pc, candidate) => { |
|
await fetch('/api/offer', { |
|
method: "PATCH", |
|
headers: { "Content-Type": "application/json" }, |
|
body: JSON.stringify({ |
|
pc_id: pc.pc_id, |
|
candidates: [{ |
|
candidate: candidate.candidate, |
|
sdp_mid: candidate.sdpMid, |
|
sdp_mline_index: candidate.sdpMLineIndex |
|
}] |
|
}) |
|
}); |
|
}; |
|
|
|
const connect = async () => { |
|
setStatus("Connecting…", "connecting") |
|
buttonEl.classList.add("active") |
|
buttonEl.textContent = "⏹️" |
|
connected = true |
|
|
|
try { |
|
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
|
const config = { |
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }] |
|
}; |
|
const pc = new RTCPeerConnection(config) |
|
peerConnection = pc |
|
pc.pendingIceCandidates = [] |
|
pc.canSendIceCandidates = false |
|
|
|
pc.ontrack = e => audioEl.srcObject = e.streams[0] |
|
pc.addTransceiver(audioStream.getAudioTracks()[0], { direction: 'sendrecv' }) |
|
pc.addTransceiver('video', { direction: 'sendrecv' }) |
|
|
|
pc.onconnectionstatechange = () => { |
|
if (pc.connectionState === 'connected') { |
|
setStatus("Connected — speak anytime", "connected") |
|
} else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { |
|
disconnect() |
|
} |
|
} |
|
|
|
pc.onicecandidate = async (event) => { |
|
if (event.candidate) { |
|
if (pc.canSendIceCandidates && pc.pc_id) { |
|
await sendIceCandidate(pc, event.candidate) |
|
} else { |
|
pc.pendingIceCandidates.push(event.candidate) |
|
} |
|
} |
|
}; |
|
|
|
await pc.setLocalDescription(await pc.createOffer()) |
|
const response = await fetch('/api/offer', { |
|
body: JSON.stringify({ sdp: pc.localDescription.sdp, type: pc.localDescription.type }), |
|
headers: { 'Content-Type': 'application/json' }, |
|
method: 'POST', |
|
}); |
|
const answer = await response.json() |
|
pc.pc_id = answer.pc_id |
|
await pc.setRemoteDescription(answer) |
|
|
|
pc.canSendIceCandidates = true |
|
for (const candidate of pc.pendingIceCandidates) { |
|
await sendIceCandidate(pc, candidate) |
|
} |
|
pc.pendingIceCandidates = [] |
|
} catch (err) { |
|
console.error(err) |
|
setStatus("Error: " + err.message, "error") |
|
disconnect() |
|
} |
|
} |
|
|
|
const disconnect = () => { |
|
if (peerConnection) { |
|
peerConnection.close() |
|
peerConnection = null |
|
} |
|
connected = false |
|
buttonEl.classList.remove("active") |
|
buttonEl.textContent = "🎙️" |
|
setStatus("Ready", "") |
|
} |
|
|
|
const setStatus = (text, cls) => { |
|
statusEl.textContent = text |
|
statusEl.className = cls || "" |
|
} |
|
|
|
buttonEl.addEventListener("click", () => { |
|
if (!connected) connect() |
|
else disconnect() |
|
}) |
|
</script> |
|
</body> |
|
</html> |
Porte 0 . Disco duro 255.0.0.0