| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> |
| <title>VoiceConnect Pro - Hi-Fi Audio (Spotify Quality)</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script> |
| <script src="https://unpkg.com/lucide@latest"></script> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap'); |
| |
| body { |
| font-family: 'Plus Jakarta Sans', sans-serif; |
| background: #020617; |
| color: #f8fafc; |
| margin: 0; |
| overflow: hidden; |
| touch-action: manipulation; |
| } |
| |
| .call-overlay { |
| position: fixed; |
| inset: 0; |
| background: radial-gradient(circle at center, #1e293b 0%, #020617 100%); |
| z-index: 9999; |
| transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .key-btn { |
| background: rgba(30, 41, 59, 0.6); |
| backdrop-filter: blur(12px); |
| border: 1px solid rgba(255, 255, 255, 0.1); |
| aspect-ratio: 1/1; |
| border-radius: 1.5rem; |
| font-weight: 700; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: 0.15s; |
| } |
| .key-btn:active { transform: scale(0.92); background: rgba(59, 130, 246, 0.4); } |
| |
| .settings-panel { |
| position: fixed; |
| inset: 0; |
| background: #020617; |
| z-index: 10001; |
| transform: translateX(100%); |
| transition: 0.3s cubic-bezier(0.16, 1, 0.3, 1); |
| overflow-y: auto; |
| } |
| .settings-panel.open { transform: translateX(0); } |
| |
| .infinite-bar { |
| background: linear-gradient(90deg, #8b5cf6 0%, #3b82f6 50%, #8b5cf6 100%); |
| background-size: 200% 100%; |
| animation: move-gradient 2s linear infinite; |
| } |
| @keyframes move-gradient { |
| 0% { background-position: 200% 0%; } |
| 100% { background-position: 0% 0%; } |
| } |
| |
| .audio-pulse { |
| width: 140px; |
| height: 140px; |
| background: rgba(139, 92, 246, 0.2); |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| } |
| .audio-pulse::before, .audio-pulse::after { |
| content: ''; |
| position: absolute; |
| border: 2px solid #8b5cf6; |
| inset: 0; |
| border-radius: 50%; |
| animation: pulse-ring 2s cubic-bezier(0.21, 0.61, 0.35, 1) infinite; |
| } |
| .audio-pulse::after { animation-delay: 1s; } |
| |
| @keyframes pulse-ring { |
| 0% { transform: scale(0.8); opacity: 0.8; } |
| 100% { transform: scale(2.8); opacity: 0; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="min-h-screen flex flex-col p-6 max-w-md mx-auto relative z-10 pb-10"> |
| <header class="mb-6"> |
| <div class="flex justify-between items-center mb-4"> |
| <h1 class="font-bold flex items-center gap-2 text-purple-400 text-xl italic">VoiceConnect <span class="text-[9px] bg-purple-600 px-2 py-0.5 rounded-full text-white not-italic">HI-FI</span></h1> |
| <div class="flex items-center gap-3"> |
| <div id="my-id-display" class="font-mono text-purple-400 font-bold bg-purple-500/10 px-3 py-1 rounded-lg text-sm border border-purple-500/20 italic tracking-tighter">Connecting...</div> |
| <button onclick="toggleSettings()" class="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-2xl border border-white/5 shadow-lg active:scale-90 transition-all"><i data-lucide="settings" size="20"></i></button> |
| </div> |
| </div> |
| <div class="bg-slate-900/80 p-5 rounded-[2.5rem] border border-purple-500/20 shadow-2xl backdrop-blur-md"> |
| <div class="flex justify-between items-end mb-3"> |
| <span class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">CHẤT LƯỢNG: LOSSLESS RAW</span> |
| <span id="data-status" class="text-2xl font-black text-white tracking-tighter flex items-center gap-2"> |
| <span id="data-used">0 MB</span> |
| <span class="text-purple-500 text-3xl">∞</span> |
| </span> |
| </div> |
| <div class="w-full bg-slate-800 h-2 rounded-full overflow-hidden"> |
| <div class="infinite-bar h-full w-full"></div> |
| </div> |
| </div> |
| </header> |
|
|
| <main class="flex-1 flex flex-col items-center"> |
| <input type="text" id="target-id-input" readonly class="w-full bg-transparent text-center text-6xl font-black text-white py-6 outline-none tracking-tighter" placeholder="0000"> |
| <div class="grid grid-cols-3 gap-4 w-full mb-8" id="keypad"></div> |
| <button id="call-btn" class="w-24 h-24 bg-purple-600 rounded-full flex items-center justify-center shadow-[0_20px_50px_rgba(139,92,246,0.4)] active:scale-95 transition-all mb-10"> |
| <i data-lucide="headphones" class="text-white" size="40"></i> |
| </button> |
| </main> |
| </div> |
|
|
| |
| <div id="call-overlay" class="call-overlay translate-y-full"> |
| <audio id="remote-audio" autoplay></audio> |
| |
| <div class="flex-1 flex flex-col items-center justify-center pt-20"> |
| <div id="status-animation" class="audio-pulse mb-12 hidden"> |
| <div class="w-16 h-16 bg-purple-600 rounded-full flex items-center justify-center z-10 shadow-[0_0_30px_rgba(139,92,246,0.5)]"> |
| <i data-lucide="music" class="text-white animate-bounce"></i> |
| </div> |
| </div> |
| <h2 id="display-peer-id" class="text-5xl font-black text-white mb-2 tracking-tighter">---</h2> |
| <p id="call-status" class="text-purple-400 font-bold text-xs uppercase tracking-[0.4em]">Streaming Raw Audio</p> |
| </div> |
|
|
| <div class="w-full p-10 pb-20 space-y-6"> |
| <div class="flex justify-center gap-8 mb-4"> |
| <button id="toggle-mic" class="w-20 h-20 rounded-full bg-white/10 backdrop-blur-2xl flex items-center justify-center border border-white/20 text-white active:bg-purple-600 transition-colors"> |
| <i data-lucide="mic" size="28"></i> |
| </button> |
| </div> |
| <div class="flex gap-4"> |
| <button id="accept-btn" class="flex-1 py-7 bg-emerald-500 text-white rounded-[2.5rem] hidden font-black text-xl shadow-2xl">TRẢ LỜI</button> |
| <button id="hangup-btn" class="flex-1 py-7 bg-red-500 text-white rounded-[2.5rem] font-black text-xl shadow-2xl">NGẮT KẾT NỐI</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="settings-panel" class="settings-panel p-8 flex flex-col"> |
| <div class="flex items-center justify-between mb-8"> |
| <button onclick="toggleSettings()" class="w-12 h-12 bg-slate-800 rounded-2xl flex items-center justify-center active:scale-90"><i data-lucide="arrow-left"></i></button> |
| <h2 class="text-2xl font-black uppercase tracking-tighter text-purple-400">Hi-Fi Setup</h2> |
| <div class="w-12"></div> |
| </div> |
| |
| <div class="space-y-6"> |
| <div class="bg-slate-900/50 p-6 rounded-[2rem] border border-white/5"> |
| <label class="text-[10px] font-bold text-slate-500 uppercase mb-3 block tracking-widest">Phone ID</label> |
| <input type="number" id="setting-phone-input" class="w-full bg-slate-950 border border-purple-500/30 p-4 rounded-2xl text-white font-bold text-2xl outline-none text-center" placeholder="0000"> |
| </div> |
|
|
| <div class="bg-slate-900/50 p-6 rounded-[2rem] border border-white/5"> |
| <label class="text-[10px] font-bold text-slate-500 uppercase mb-3 block tracking-widest">Input Device</label> |
| <select id="mic-select" class="w-full bg-slate-950 border border-white/10 p-4 rounded-2xl text-white outline-none text-sm"></select> |
| </div> |
| |
| <div class="p-6 border border-purple-500/20 rounded-[2rem] bg-purple-500/5"> |
| <h4 class="text-purple-300 font-bold text-xs uppercase mb-2 tracking-widest">Studio Mode Active</h4> |
| <ul class="text-[10px] text-slate-400 space-y-1"> |
| <li>• Sample Rate: 48,000Hz</li> |
| <li>• Channel: Stereo (Raw Capture)</li> |
| <li>• Bitrate: Uncapped (Dynamic Max)</li> |
| <li>• DSP Filters: Disabled</li> |
| </ul> |
| </div> |
| </div> |
|
|
| <button onclick="saveSettings()" class="w-full bg-purple-600 py-6 rounded-[2rem] font-black text-xl mt-10 shadow-2xl active:scale-95 transition-all">LƯU CẤU HÌNH</button> |
| </div> |
|
|
| <script> |
| lucide.createIcons(); |
| |
| let dataUsed = parseFloat(localStorage.getItem('voice_data_infinite') || 0); |
| let myPhone = localStorage.getItem('user_fixed_phone'); |
| let selectedMic = localStorage.getItem('selected_mic') || ''; |
| let peer, localStream, currentCall, wakeLock = null; |
| let isMuted = false; |
| |
| async function loadDevices() { |
| try { |
| await navigator.mediaDevices.getUserMedia({ audio: true }); |
| const devices = await navigator.mediaDevices.enumerateDevices(); |
| const micSelect = document.getElementById('mic-select'); |
| micSelect.innerHTML = ''; |
| |
| devices.forEach(device => { |
| if (device.kind === 'audioinput') { |
| const opt = document.createElement('option'); |
| opt.value = device.deviceId; |
| opt.text = device.label || `Mic (${device.deviceId.slice(0,5)})`; |
| if (device.deviceId === selectedMic) opt.selected = true; |
| micSelect.appendChild(opt); |
| } |
| }); |
| } catch (e) { console.error("Permission denied", e); } |
| } |
| |
| async function getAudioStream() { |
| if (localStream) localStream.getTracks().forEach(t => t.stop()); |
| |
| |
| localStream = await navigator.mediaDevices.getUserMedia({ |
| audio: { |
| deviceId: selectedMic ? { exact: selectedMic } : undefined, |
| echoCancellation: false, |
| noiseSuppression: false, |
| autoGainControl: false, |
| |
| sampleRate: 48000, |
| sampleSize: 16, |
| channelCount: 2 |
| }, |
| video: false |
| }); |
| return localStream; |
| } |
| |
| if(!myPhone) { setTimeout(() => toggleSettings(), 500); } else { initPeer(myPhone); } |
| loadDevices(); |
| |
| function initPeer(phone) { |
| if(peer) peer.destroy(); |
| peer = new Peer(phone, { |
| config: { |
| 'iceServers': [{ urls: 'stun:stun.l.google.com:19302' }], |
| 'sdpSemantics': 'unified-plan' |
| } |
| }); |
| peer.on('open', id => { |
| document.getElementById('my-id-display').innerText = id; |
| }); |
| peer.on('call', async call => { |
| if(currentCall) return; |
| currentCall = call; |
| showOverlay(call.peer, "Incoming Studio Stream..."); |
| document.getElementById('accept-btn').style.display = 'block'; |
| if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); |
| }); |
| } |
| |
| document.getElementById('call-btn').onclick = async () => { |
| const id = document.getElementById('target-id-input').value; |
| if (id.length < 3) return; |
| showOverlay(id, "Initializing Hi-Fi..."); |
| try { |
| localStream = await getAudioStream(); |
| currentCall = peer.call(id, localStream); |
| setupCall(currentCall); |
| } catch (e) { alert("Mic Error!"); } |
| }; |
| |
| document.getElementById('accept-btn').onclick = async () => { |
| try { |
| localStream = await getAudioStream(); |
| currentCall.answer(localStream); |
| setupCall(currentCall); |
| document.getElementById('accept-btn').style.display = 'none'; |
| } catch (e) { alert("Mic Error!"); } |
| }; |
| |
| function setupCall(call) { |
| currentCall = call; |
| |
| |
| const pc = call.peerConnection; |
| pc.onicecandidate = () => { |
| const senders = pc.getSenders(); |
| senders.forEach(sender => { |
| if (sender.track.kind === 'audio') { |
| const parameters = sender.getParameters(); |
| if (!parameters.encodings) parameters.encodings = [{}]; |
| |
| delete parameters.encodings[0].maxBitrate; |
| sender.setParameters(parameters); |
| } |
| }); |
| }; |
| |
| call.on('stream', stream => { |
| const remoteAudio = document.getElementById('remote-audio'); |
| remoteAudio.srcObject = stream; |
| |
| remoteAudio.play(); |
| document.getElementById('call-status').innerText = "LIVE • FULL QUALITY"; |
| document.getElementById('status-animation').classList.remove('hidden'); |
| startUsage(); |
| }); |
| call.on('close', hangup); |
| call.on('error', hangup); |
| } |
| |
| function hangup() { |
| if (currentCall) currentCall.close(); |
| if (localStream) localStream.getTracks().forEach(t => t.stop()); |
| currentCall = null; |
| document.getElementById('call-overlay').classList.add('translate-y-full'); |
| document.getElementById('remote-audio').srcObject = null; |
| document.getElementById('status-animation').classList.add('hidden'); |
| clearInterval(window.usageInt); |
| if (wakeLock) wakeLock.release().then(() => wakeLock = null); |
| } |
| |
| document.getElementById('toggle-mic').onclick = () => { |
| isMuted = !isMuted; |
| if (localStream) { |
| localStream.getAudioTracks()[0].enabled = !isMuted; |
| document.getElementById('toggle-mic').classList.toggle('bg-red-500', isMuted); |
| document.getElementById('toggle-mic').innerHTML = isMuted ? '<i data-lucide="mic-off" size="28"></i>' : '<i data-lucide="mic" size="28"></i>'; |
| lucide.createIcons(); |
| } |
| }; |
| |
| const keys = ['1','2','3','4','5','6','7','8','9','X','0','D']; |
| keys.forEach(k => { |
| const b = document.createElement('button'); |
| b.className = 'key-btn text-white text-3xl font-black shadow-lg'; |
| b.innerHTML = k === 'X' ? '<i data-lucide="trash-2" class="text-red-400"></i>' : k === 'D' ? '<i data-lucide="delete"></i>' : k; |
| b.onclick = () => { |
| const input = document.getElementById('target-id-input'); |
| if(k==='X') input.value = ''; |
| else if(k==='D') input.value = input.value.slice(0,-1); |
| else if(input.value.length < 11) input.value += k; |
| lucide.createIcons(); |
| }; |
| document.getElementById('keypad').appendChild(b); |
| }); |
| |
| function showOverlay(id, status) { |
| document.getElementById('display-peer-id').innerText = id; |
| document.getElementById('call-status').innerText = status; |
| document.getElementById('call-overlay').classList.remove('translate-y-full'); |
| } |
| |
| function startUsage() { |
| if(window.usageInt) clearInterval(window.usageInt); |
| window.usageInt = setInterval(() => { |
| |
| dataUsed += (1.0 / 60); |
| localStorage.setItem('voice_data_infinite', dataUsed); |
| document.getElementById('data-used').innerText = dataUsed.toFixed(2) + " MB"; |
| }, 1000); |
| } |
| |
| function toggleSettings() { |
| document.getElementById('settings-panel').classList.toggle('open'); |
| if(document.getElementById('settings-panel').classList.contains('open')) loadDevices(); |
| } |
| |
| function saveSettings() { |
| const v = document.getElementById('setting-phone-input').value; |
| const mic = document.getElementById('mic-select').value; |
| if(v.length < 3) return alert("ID too short!"); |
| localStorage.setItem('user_fixed_phone', v); |
| localStorage.setItem('selected_mic', mic); |
| location.reload(); |
| } |
| |
| document.getElementById('hangup-btn').onclick = hangup; |
| </script> |
| </body> |
| </html> |