|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
|
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>AI Interview Assistant</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
:root { |
|
|
--primary: #ff7849; |
|
|
--primary-dark: #ff5722; |
|
|
--primary-light: #ffab91; |
|
|
--bg-dark: #1a1f2e; |
|
|
--bg-surface: #242b3d; |
|
|
--bg-elevated: #2d3548; |
|
|
--text-primary: #ffffff; |
|
|
--text-secondary: #e8eaed; |
|
|
--text-muted: #b8bcc8; |
|
|
--success: #4ade80; |
|
|
--warning: #fbbf24; |
|
|
--error: #f87171; |
|
|
--accent-glow: rgba(255, 120, 73, 0.3); |
|
|
} |
|
|
|
|
|
body { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
background: linear-gradient(135deg, #1a1f2e 0%, #0f1419 100%); |
|
|
background-attachment: fixed; |
|
|
color: var(--text-primary); |
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif; |
|
|
min-height: 100vh; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
position: relative; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
|
|
|
body::before { |
|
|
content: ''; |
|
|
position: fixed; |
|
|
top: -50%; |
|
|
left: -50%; |
|
|
width: 200%; |
|
|
height: 200%; |
|
|
background: radial-gradient(circle at 50% 50%, rgba(255, 120, 73, 0.15) 0%, transparent 50%); |
|
|
animation: rotate 20s linear infinite; |
|
|
pointer-events: none; |
|
|
z-index: 0; |
|
|
} |
|
|
|
|
|
@keyframes rotate { |
|
|
0% { |
|
|
transform: rotate(0deg); |
|
|
} |
|
|
|
|
|
100% { |
|
|
transform: rotate(360deg); |
|
|
} |
|
|
} |
|
|
|
|
|
.header { |
|
|
text-align: center; |
|
|
margin-bottom: 1.5rem; |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 2rem; |
|
|
font-weight: 700; |
|
|
margin-bottom: 0.5rem; |
|
|
background: linear-gradient(135deg, #ff7849 0%, #ffab91 100%); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
letter-spacing: -0.02em; |
|
|
filter: drop-shadow(0 0 20px rgba(255, 120, 73, 0.3)); |
|
|
} |
|
|
|
|
|
.header p { |
|
|
font-size: 1rem; |
|
|
color: var(--text-secondary); |
|
|
font-weight: 400; |
|
|
} |
|
|
|
|
|
.container { |
|
|
width: 90%; |
|
|
max-width: 500px; |
|
|
background: var(--bg-surface); |
|
|
backdrop-filter: blur(10px); |
|
|
-webkit-backdrop-filter: blur(10px); |
|
|
padding: 2rem; |
|
|
border-radius: 1rem; |
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 120, 73, 0.1); |
|
|
border: 1px solid var(--bg-elevated); |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
.status-indicator { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 0.75rem; |
|
|
padding: 1rem; |
|
|
background: rgba(255, 120, 73, 0.08); |
|
|
border-radius: 0.75rem; |
|
|
margin-bottom: 1.5rem; |
|
|
border: 1px solid rgba(255, 120, 73, 0.2); |
|
|
box-shadow: 0 0 20px rgba(255, 120, 73, 0.1); |
|
|
} |
|
|
|
|
|
.status-dot { |
|
|
width: 10px; |
|
|
height: 10px; |
|
|
border-radius: 50%; |
|
|
background: var(--success); |
|
|
box-shadow: 0 0 10px currentColor; |
|
|
animation: pulse 2s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
.status-dot.connecting { |
|
|
background: var(--warning); |
|
|
} |
|
|
|
|
|
.status-dot.active { |
|
|
background: var(--primary); |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
50% { |
|
|
opacity: 0.5; |
|
|
} |
|
|
} |
|
|
|
|
|
.status-text { |
|
|
font-size: 0.875rem; |
|
|
font-weight: 500; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.visualizer { |
|
|
min-height: 120px; |
|
|
padding: 1.5rem; |
|
|
margin: 1.5rem 0; |
|
|
background: linear-gradient(135deg, rgba(255, 120, 73, 0.1) 0%, rgba(255, 171, 145, 0.05) 100%); |
|
|
border-radius: 1rem; |
|
|
border: 1px solid rgba(255, 120, 73, 0.2); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
box-shadow: inset 0 0 30px rgba(255, 120, 73, 0.05); |
|
|
} |
|
|
|
|
|
.bars-container { |
|
|
display: flex; |
|
|
align-items: flex-end; |
|
|
justify-content: center; |
|
|
gap: 3px; |
|
|
height: 60px; |
|
|
} |
|
|
|
|
|
.bar { |
|
|
width: 5px; |
|
|
background: linear-gradient(to top, var(--primary), var(--primary-light)); |
|
|
border-radius: 3px; |
|
|
transition: height 0.1s ease; |
|
|
box-shadow: 0 0 12px var(--accent-glow), 0 0 4px var(--primary); |
|
|
} |
|
|
|
|
|
.button { |
|
|
width: 100%; |
|
|
padding: 0.75rem 1.5rem; |
|
|
font-size: 1rem; |
|
|
font-weight: 600; |
|
|
border: none; |
|
|
border-radius: 0.5rem; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
font-family: inherit; |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.button-primary { |
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); |
|
|
color: white; |
|
|
box-shadow: 0 4px 16px var(--accent-glow), 0 0 0 1px rgba(255, 120, 73, 0.3); |
|
|
} |
|
|
|
|
|
.button-primary:hover:not(:disabled) { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 8px 24px var(--accent-glow), 0 0 0 1px var(--primary); |
|
|
filter: brightness(1.1); |
|
|
} |
|
|
|
|
|
.button-primary:active:not(:disabled) { |
|
|
transform: translateY(0); |
|
|
} |
|
|
|
|
|
.button-primary:disabled { |
|
|
opacity: 0.5; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.button-stop { |
|
|
background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%); |
|
|
color: white; |
|
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); |
|
|
} |
|
|
|
|
|
.button-stop:hover:not(:disabled) { |
|
|
transform: translateY(-1px); |
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); |
|
|
} |
|
|
|
|
|
.status-dot { |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
border-radius: 50%; |
|
|
background: var(--text-muted); |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.status-dot.ready { |
|
|
background: var(--success); |
|
|
animation: pulse 2s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
.status-dot.connecting { |
|
|
background: var(--warning); |
|
|
animation: pulse 1s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
.status-dot.active { |
|
|
background: var(--primary); |
|
|
animation: pulse 1.5s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
opacity: 1; |
|
|
transform: scale(1); |
|
|
} |
|
|
|
|
|
50% { |
|
|
opacity: 0.5; |
|
|
transform: scale(1.2); |
|
|
} |
|
|
} |
|
|
|
|
|
.status-text { |
|
|
font-size: 0.875rem; |
|
|
font-weight: 500; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.visualizer { |
|
|
position: relative; |
|
|
display: flex; |
|
|
min-height: 80px; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
margin: 1.5rem 0; |
|
|
padding: 1.5rem; |
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(79, 70, 229, 0.05) 100%); |
|
|
border-radius: 1rem; |
|
|
border: 1px solid rgba(99, 102, 241, 0.1); |
|
|
} |
|
|
|
|
|
.bars-container { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
height: 50px; |
|
|
width: 100%; |
|
|
gap: 3px; |
|
|
} |
|
|
|
|
|
.bar { |
|
|
flex: 1; |
|
|
height: 100%; |
|
|
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%); |
|
|
border-radius: 8px; |
|
|
transition: transform 0.05s ease; |
|
|
transform: scaleY(0.1); |
|
|
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3); |
|
|
} |
|
|
|
|
|
.controls { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 1rem; |
|
|
} |
|
|
|
|
|
button { |
|
|
padding: 1.25rem 2rem; |
|
|
border-radius: 1rem; |
|
|
border: none; |
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); |
|
|
color: white; |
|
|
font-weight: 600; |
|
|
font-size: 1.1rem; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 0.75rem; |
|
|
box-shadow: |
|
|
0 10px 30px var(--accent-glow), |
|
|
0 0 0 1px rgba(255, 120, 73, 0.4); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
button::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: -100%; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); |
|
|
transition: left 0.5s ease; |
|
|
} |
|
|
|
|
|
button:hover::before { |
|
|
left: 100%; |
|
|
} |
|
|
|
|
|
button:hover { |
|
|
transform: translateY(-3px); |
|
|
box-shadow: |
|
|
0 15px 40px var(--accent-glow), |
|
|
0 0 0 1px var(--primary), |
|
|
0 0 30px rgba(255, 120, 73, 0.2); |
|
|
filter: brightness(1.15); |
|
|
} |
|
|
|
|
|
button:active { |
|
|
transform: translateY(0); |
|
|
} |
|
|
|
|
|
button:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.button-content { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.75rem; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
border: 2px solid rgba(255, 255, 255, 0.3); |
|
|
border-top-color: white; |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
to { |
|
|
transform: rotate(360deg); |
|
|
} |
|
|
} |
|
|
|
|
|
.pulse-indicator { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
border-radius: 50%; |
|
|
background-color: white; |
|
|
animation: pulse-scale 1.5s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse-scale { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
transform: scale(1); |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
50% { |
|
|
transform: scale(1.3); |
|
|
opacity: 0.7; |
|
|
} |
|
|
} |
|
|
|
|
|
.toast { |
|
|
position: fixed; |
|
|
top: 2rem; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
padding: 1rem 1.5rem; |
|
|
border-radius: 0.75rem; |
|
|
font-size: 0.875rem; |
|
|
font-weight: 500; |
|
|
z-index: 1000; |
|
|
display: none; |
|
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); |
|
|
backdrop-filter: blur(10px); |
|
|
-webkit-backdrop-filter: blur(10px); |
|
|
} |
|
|
|
|
|
.toast.error { |
|
|
background: rgba(239, 68, 68, 0.95); |
|
|
color: white; |
|
|
border: 1px solid rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
|
|
|
.toast.warning { |
|
|
background: rgba(245, 158, 11, 0.95); |
|
|
color: white; |
|
|
border: 1px solid rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
|
|
|
.toast.success { |
|
|
background: rgba(16, 185, 129, 0.95); |
|
|
color: white; |
|
|
border: 1px solid rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
|
|
|
.instructions { |
|
|
margin-top: 2rem; |
|
|
padding: 1.5rem; |
|
|
background: rgba(255, 120, 73, 0.08); |
|
|
border-radius: 1rem; |
|
|
border: 1px solid rgba(255, 120, 73, 0.2); |
|
|
} |
|
|
|
|
|
.instructions h3 { |
|
|
font-size: 1.1rem; |
|
|
font-weight: 600; |
|
|
margin-bottom: 0.75rem; |
|
|
color: var(--primary); |
|
|
} |
|
|
|
|
|
.instructions ol { |
|
|
margin-left: 1.25rem; |
|
|
color: var(--text-secondary); |
|
|
font-size: 0.875rem; |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
.instructions li { |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
@media (max-width: 640px) { |
|
|
.header h1 { |
|
|
font-size: 2rem; |
|
|
} |
|
|
|
|
|
.container { |
|
|
padding: 2rem 1.5rem; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
|
|
|
<body> |
|
|
<div id="error-toast" class="toast"></div> |
|
|
|
|
|
<div class="header"> |
|
|
<h1>🎙️ AI Interview Assistant</h1> |
|
|
<p>Powered by Gemini 2.5 Flash</p> |
|
|
</div> |
|
|
|
|
|
<div class="container"> |
|
|
<div class="visualizer"> |
|
|
<div class="bars-container" id="bars-container"> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="controls"> |
|
|
<button id="start-button"> |
|
|
<span class="button-content"> |
|
|
<span id="button-text">Start Interview</span> |
|
|
</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<audio id="audio-output"></audio> |
|
|
|
|
|
<script> |
|
|
let peerConnection; |
|
|
let audioContext; |
|
|
let dataChannel; |
|
|
let isRecording = false; |
|
|
let webrtc_id; |
|
|
let isMuted = false; |
|
|
let analyser_input, dataArray_input; |
|
|
let analyser, dataArray; |
|
|
let source_input = null; |
|
|
let source_output = null; |
|
|
|
|
|
const startButton = document.getElementById('start-button'); |
|
|
const buttonText = document.getElementById('button-text'); |
|
|
const audioOutput = document.getElementById('audio-output'); |
|
|
const barsContainer = document.getElementById('bars-container'); |
|
|
|
|
|
|
|
|
const numBars = 32; |
|
|
for (let i = 0; i < numBars; i++) { |
|
|
const bar = document.createElement('div'); |
|
|
bar.className = 'bar'; |
|
|
barsContainer.appendChild(bar); |
|
|
} |
|
|
|
|
|
function updateButtonState() { |
|
|
if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) { |
|
|
buttonText.innerHTML = '<div class="spinner"></div> Connecting...'; |
|
|
startButton.disabled = true; |
|
|
} else if (peerConnection && peerConnection.connectionState === 'connected') { |
|
|
buttonText.innerHTML = '<div class="pulse-indicator"></div> Stop Interview'; |
|
|
startButton.disabled = false; |
|
|
} else { |
|
|
buttonText.textContent = 'Start Interview'; |
|
|
startButton.disabled = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function showToast(message, type = 'error') { |
|
|
const toast = document.getElementById('error-toast'); |
|
|
toast.textContent = message; |
|
|
toast.className = `toast ${type}`; |
|
|
toast.style.display = 'block'; |
|
|
|
|
|
setTimeout(() => { |
|
|
toast.style.display = 'none'; |
|
|
}, 5000); |
|
|
} |
|
|
|
|
|
async function setupWebRTC() { |
|
|
const config = __RTC_CONFIGURATION__; |
|
|
peerConnection = new RTCPeerConnection(config); |
|
|
webrtc_id = Math.random().toString(36).substring(7); |
|
|
|
|
|
const timeoutId = setTimeout(() => { |
|
|
if (peerConnection && peerConnection.connectionState !== 'connected') { |
|
|
showToast("Still connecting... This may take up to 30 seconds.", 'warning'); |
|
|
} |
|
|
}, 5000); |
|
|
|
|
|
try { |
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
|
|
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream)); |
|
|
|
|
|
if (!audioContext || audioContext.state === 'closed') { |
|
|
audioContext = new AudioContext(); |
|
|
} |
|
|
if (source_input) { |
|
|
try { source_input.disconnect(); } catch (e) { } |
|
|
source_input = null; |
|
|
} |
|
|
source_input = audioContext.createMediaStreamSource(stream); |
|
|
analyser_input = audioContext.createAnalyser(); |
|
|
source_input.connect(analyser_input); |
|
|
analyser_input.fftSize = 64; |
|
|
dataArray_input = new Uint8Array(analyser_input.frequencyBinCount); |
|
|
updateAudioLevel(); |
|
|
|
|
|
peerConnection.addEventListener('connectionstatechange', () => { |
|
|
console.log('Connection state:', peerConnection.connectionState); |
|
|
if (peerConnection.connectionState === 'connected') { |
|
|
clearTimeout(timeoutId); |
|
|
const toast = document.getElementById('error-toast'); |
|
|
toast.style.display = 'none'; |
|
|
showToast('Connected successfully!', 'success'); |
|
|
setTimeout(() => { |
|
|
document.getElementById('error-toast').style.display = 'none'; |
|
|
}, 2000); |
|
|
if (analyser_input) updateAudioLevel(); |
|
|
if (analyser) updateVisualization(); |
|
|
} else if (['disconnected', 'failed', 'closed'].includes(peerConnection.connectionState)) { |
|
|
clearTimeout(timeoutId); |
|
|
if (peerConnection.connectionState === 'failed') { |
|
|
showToast('Connection failed. Please try again.', 'error'); |
|
|
} |
|
|
} |
|
|
updateButtonState(); |
|
|
}); |
|
|
|
|
|
peerConnection.onicecandidate = ({ candidate }) => { |
|
|
if (candidate) { |
|
|
fetch('/webrtc/offer', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
candidate: candidate.toJSON(), |
|
|
webrtc_id: webrtc_id, |
|
|
type: "ice-candidate", |
|
|
}) |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
peerConnection.addEventListener('track', (evt) => { |
|
|
if (evt.track.kind === 'audio' && audioOutput) { |
|
|
if (audioOutput.srcObject !== evt.streams[0]) { |
|
|
audioOutput.srcObject = evt.streams[0]; |
|
|
audioOutput.play().catch(e => console.error("Audio play failed:", e)); |
|
|
|
|
|
if (!audioContext || audioContext.state === 'closed') { |
|
|
return; |
|
|
} |
|
|
if (source_output) { |
|
|
try { source_output.disconnect(); } catch (e) { } |
|
|
source_output = null; |
|
|
} |
|
|
source_output = audioContext.createMediaStreamSource(evt.streams[0]); |
|
|
analyser = audioContext.createAnalyser(); |
|
|
source_output.connect(analyser); |
|
|
analyser.fftSize = 2048; |
|
|
dataArray = new Uint8Array(analyser.frequencyBinCount); |
|
|
updateVisualization(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
dataChannel = peerConnection.createDataChannel('text'); |
|
|
dataChannel.onmessage = (event) => { |
|
|
const eventJson = JSON.parse(event.data); |
|
|
if (eventJson.type === "error") { |
|
|
showToast(eventJson.message, 'error'); |
|
|
} else if (eventJson.type === "send_input") { |
|
|
fetch('/input_hook', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
webrtc_id: webrtc_id, |
|
|
api_key: "", |
|
|
voice_name: "Puck" |
|
|
}) |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
const offer = await peerConnection.createOffer(); |
|
|
await peerConnection.setLocalDescription(offer); |
|
|
|
|
|
const response = await fetch('/webrtc/offer', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
sdp: peerConnection.localDescription.sdp, |
|
|
type: peerConnection.localDescription.type, |
|
|
webrtc_id: webrtc_id, |
|
|
}) |
|
|
}); |
|
|
|
|
|
const serverResponse = await response.json(); |
|
|
|
|
|
if (serverResponse.status === 'failed') { |
|
|
showToast(serverResponse.meta.error === 'concurrency_limit_reached' |
|
|
? `Too many connections. Maximum limit is ${serverResponse.meta.limit}` |
|
|
: serverResponse.meta.error, 'error'); |
|
|
stopWebRTC(); |
|
|
return; |
|
|
} |
|
|
|
|
|
await peerConnection.setRemoteDescription(serverResponse); |
|
|
} catch (err) { |
|
|
clearTimeout(timeoutId); |
|
|
console.error('Error setting up WebRTC:', err); |
|
|
showToast('Failed to establish connection. Please try again.', 'error'); |
|
|
stopWebRTC(); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateVisualization() { |
|
|
if (!analyser || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) { |
|
|
const bars = document.querySelectorAll('.bar'); |
|
|
bars.forEach(bar => bar.style.transform = 'scaleY(0.1)'); |
|
|
return; |
|
|
} |
|
|
|
|
|
analyser.getByteFrequencyData(dataArray); |
|
|
const bars = document.querySelectorAll('.bar'); |
|
|
|
|
|
for (let i = 0; i < bars.length; i++) { |
|
|
const barHeight = (dataArray[i] / 255) * 2; |
|
|
bars[i].style.transform = `scaleY(${Math.max(0.1, barHeight)})`; |
|
|
} |
|
|
|
|
|
requestAnimationFrame(updateVisualization); |
|
|
} |
|
|
|
|
|
function updateAudioLevel() { |
|
|
if (!analyser_input || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) { |
|
|
return; |
|
|
} |
|
|
analyser_input.getByteFrequencyData(dataArray_input); |
|
|
requestAnimationFrame(updateAudioLevel); |
|
|
} |
|
|
|
|
|
function stopWebRTC() { |
|
|
console.log("Stopping WebRTC"); |
|
|
if (peerConnection) { |
|
|
peerConnection.getSenders().forEach(sender => { |
|
|
if (sender.track) { |
|
|
sender.track.stop(); |
|
|
} |
|
|
}); |
|
|
peerConnection.ontrack = null; |
|
|
peerConnection.onicecandidate = null; |
|
|
peerConnection.onconnectionstatechange = null; |
|
|
|
|
|
if (dataChannel) { |
|
|
dataChannel.onmessage = null; |
|
|
try { dataChannel.close(); } catch (e) { } |
|
|
dataChannel = null; |
|
|
} |
|
|
try { peerConnection.close(); } catch (e) { } |
|
|
peerConnection = null; |
|
|
} |
|
|
|
|
|
if (audioOutput) { |
|
|
audioOutput.pause(); |
|
|
audioOutput.srcObject = null; |
|
|
} |
|
|
|
|
|
if (source_input) { |
|
|
try { source_input.disconnect(); } catch (e) { } |
|
|
source_input = null; |
|
|
} |
|
|
if (source_output) { |
|
|
try { source_output.disconnect(); } catch (e) { } |
|
|
source_output = null; |
|
|
} |
|
|
|
|
|
if (audioContext && audioContext.state !== 'closed') { |
|
|
audioContext.close().then(() => { |
|
|
audioContext = null; |
|
|
}).catch(e => { |
|
|
audioContext = null; |
|
|
}); |
|
|
} else { |
|
|
audioContext = null; |
|
|
} |
|
|
|
|
|
analyser_input = null; |
|
|
dataArray_input = null; |
|
|
analyser = null; |
|
|
dataArray = null; |
|
|
|
|
|
isMuted = false; |
|
|
isRecording = false; |
|
|
updateButtonState(); |
|
|
|
|
|
const bars = document.querySelectorAll('.bar'); |
|
|
bars.forEach(bar => bar.style.transform = 'scaleY(0.1)'); |
|
|
|
|
|
|
|
|
window.parent.postMessage("stop_interview", "*"); |
|
|
} |
|
|
|
|
|
startButton.addEventListener('click', () => { |
|
|
if (peerConnection && peerConnection.connectionState === 'connected') { |
|
|
stopWebRTC(); |
|
|
} else if (!peerConnection || ['new', 'closed', 'failed', 'disconnected'].includes(peerConnection?.connectionState)) { |
|
|
setupWebRTC(); |
|
|
isRecording = true; |
|
|
updateButtonState(); |
|
|
} |
|
|
}); |
|
|
|
|
|
updateButtonState(); |
|
|
</script> |
|
|
</body> |
|
|
|
|
|
</html> |