kylsprt commited on
Commit
a7e3093
·
verified ·
1 Parent(s): 30c2a23

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +527 -140
index.html CHANGED
@@ -3,139 +3,455 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
- <title>🎙️ Audio CCTV</title>
 
 
 
 
 
7
  <style>
8
- * { margin:0; padding:0; box-sizing:border-box; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  body {
10
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
11
- background: #0a0a0a; color: #e0e0e0;
12
- min-height: 100vh;
13
- display: flex; justify-content: center; align-items: center;
14
- }
15
- .c { width:100%; max-width:420px; padding:24px; }
16
- h1 { text-align:center; font-size:1.6em; margin-bottom:6px; }
17
- .sub { text-align:center; color:#666; font-size:13px; margin-bottom:24px; }
18
- input {
19
- width:100%; padding:14px; border:2px solid #222; border-radius:10px;
20
- background:#111; color:#fff; font-size:16px; text-align:center;
21
- margin-bottom:12px; outline:none;
22
- }
23
- input:focus { border-color:#00c853; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  .btn {
25
- width:100%; padding:16px; border:none; border-radius:10px;
26
- font-size:16px; font-weight:700; cursor:pointer;
27
- margin-bottom:10px; transition:all .2s;
28
- }
29
- .btn:active { transform:scale(0.97); }
30
- #connectBtn { background:#00c853; color:#000; }
31
- #connectBtn:disabled { background:#1a1a1a; color:#444; cursor:default; }
32
- #muteBtn {
33
- display:none; background:#00c853; color:#000;
34
- }
35
- #muteBtn.muted { background:#ff1744; color:#fff; }
36
- .status {
37
- text-align:center; margin:16px 0; padding:12px;
38
- border-radius:10px; background:#111; font-size:14px;
39
- }
40
- .label { font-size:12px; color:#555; margin:8px 0 4px; }
41
- .meter {
42
- width:100%; height:6px; background:#1a1a1a;
43
- border-radius:3px; overflow:hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
  .meter-fill {
46
- height:100%; width:0%; border-radius:3px;
47
- transition:width .08s linear;
 
48
  }
49
- #micFill { background:#00c853; }
50
- #spkFill { background:#2979ff; }
51
- .stats {
52
- text-align:center; font-size:11px; color:#333; margin-top:20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
 
 
 
 
 
 
54
  .tips {
55
- margin-top:24px; padding:16px; background:#111;
56
- border-radius:10px; font-size:12px; color:#555; line-height:1.6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
- .tips b { color:#888; }
59
  </style>
60
  </head>
61
  <body>
62
- <div class="c">
63
- <h1>🎙️ Audio CCTV</h1>
64
- <p class="sub">Intercom 2 arah — buka di 2 device, room sama</p>
 
 
 
 
 
 
 
65
 
66
- <input type="text" id="room" placeholder="Nama Room" value="kamar">
67
- <button class="btn" id="connectBtn">🔌 Connect</button>
68
- <button class="btn" id="muteBtn">🎤 Mic ON</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- <div class="status" id="status">⚫ Belum terhubung</div>
 
71
 
72
- <div class="label">📤 Mic</div>
73
- <div class="meter"><div class="meter-fill" id="micFill"></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- <div class="label">📥 Speaker</div>
76
- <div class="meter"><div class="meter-fill" id="spkFill"></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
- <div class="stats" id="stats"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- <div class="tips">
81
- <b>Tips HP bekas 24 jam:</b><br>
82
- • Colok charger terus<br>
83
- Matikan battery optimization untuk Chrome<br>
84
- • Setting > Display > Screen timeout > Never<br>
85
- • Setting > WiFi > Keep WiFi on during sleep > Always<br>
86
- Aktifkan Do Not Disturb
 
 
 
 
 
87
  </div>
 
88
  </div>
89
 
90
  <script>
91
  const $ = id => document.getElementById(id);
92
- const BUFFER = 4096;
 
93
 
94
  let ws, audioCtx, micStream;
95
  let captureNode, playNode, sourceNode, silentGain;
96
  let playQueue = [];
97
- let isMuted = false, isInit = false;
98
  let bytesSent = 0, bytesRecv = 0, reconnects = 0;
 
 
 
 
99
 
100
- // ========== CONNECT ==========
101
- $('connectBtn').onclick = init;
102
- $('muteBtn').onclick = toggleMute;
103
 
104
- async function init() {
105
  try {
106
- // Minta izin mic (sekali aja)
107
- if (!isInit) {
108
- micStream = await navigator.mediaDevices.getUserMedia({
109
- audio: {
110
- echoCancellation: true,
111
- noiseSuppression: true,
112
- autoGainControl: true
113
- }
114
- });
115
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
116
- if (audioCtx.state === 'suspended') await audioCtx.resume();
117
- setupAudio();
118
- isInit = true;
119
- }
120
  connectWS();
121
  } catch(e) {
122
- setStatus(' ' + e.message);
123
- alert('Izinkan akses mikrofon dulu!');
124
  }
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  // ========== WEBSOCKET ==========
128
  function connectWS() {
129
- const room = $('room').value || 'default';
130
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
131
  ws = new WebSocket(`${proto}//${location.host}/ws/${room}`);
132
  ws.binaryType = 'arraybuffer';
133
 
134
  ws.onopen = () => {
135
- setStatus(`🟢 Terhubung — Room: ${room}`);
136
- $('connectBtn').disabled = true;
137
- $('connectBtn').textContent = '🟢 Connected';
138
- $('muteBtn').style.display = 'block';
 
 
 
139
  tryWakeLock();
140
  };
141
 
@@ -150,37 +466,28 @@ function connectWS() {
150
  };
151
 
152
  ws.onclose = () => {
 
153
  reconnects++;
154
- setStatus(`🔴 Terputus — reconnect #${reconnects}...`);
155
- setTimeout(connectWS, 3000);
156
  };
157
 
158
- ws.onerror = () => setStatus('❌ Error');
159
  }
160
 
161
- // ========== AUDIO NODES ==========
162
- function setupAudio() {
163
- // --- CAPTURE: Mic → WebSocket ---
164
  sourceNode = audioCtx.createMediaStreamSource(micStream);
165
- captureNode = audioCtx.createScriptProcessor(BUFFER, 1, 1);
166
  silentGain = audioCtx.createGain();
167
- silentGain.gain.value = 0; // mic gak keluar speaker sendiri
168
 
169
  captureNode.onaudioprocess = (e) => {
170
- if (!ws || ws.readyState !== 1 || isMuted) return;
171
  const pcm = e.inputBuffer.getChannelData(0);
172
-
173
- // Mic meter
174
- let sum = 0;
175
- for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
176
- setMeter('micFill', Math.sqrt(sum / pcm.length));
177
-
178
- // Float32 → Int16
179
- const int16 = new Int16Array(pcm.length);
180
- for (let i = 0; i < pcm.length; i++) {
181
- const s = Math.max(-1, Math.min(1, pcm[i]));
182
- int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
183
- }
184
  ws.send(int16.buffer);
185
  bytesSent += int16.buffer.byteLength;
186
  };
@@ -189,58 +496,138 @@ function setupAudio() {
189
  captureNode.connect(silentGain);
190
  silentGain.connect(audioCtx.destination);
191
 
192
- // --- PLAYBACK: WebSocket → Speaker ---
193
- playNode = audioCtx.createScriptProcessor(BUFFER, 1, 1);
194
-
195
  playNode.onaudioprocess = (e) => {
196
  const out = e.outputBuffer.getChannelData(0);
197
- if (playQueue.length > 0) {
198
  const chunk = playQueue.shift();
199
  const len = Math.min(out.length, chunk.length);
200
  for (let i = 0; i < len; i++) out[i] = chunk[i];
201
  for (let i = len; i < out.length; i++) out[i] = 0;
202
- let sum = 0;
203
- for (let i = 0; i < len; i++) sum += chunk[i] * chunk[i];
204
- setMeter('spkFill', Math.sqrt(sum / len));
205
  } else {
206
  out.fill(0);
207
- setMeter('spkFill', 0);
208
  }
209
  };
210
 
211
  playNode.connect(audioCtx.destination);
 
 
 
212
 
213
- // Stats update
214
- setInterval(() => {
215
- const s = (bytesSent/1024).toFixed(0);
216
- const r = (bytesRecv/1024).toFixed(0);
217
- $('stats').textContent = `📤 ${s} KB | 📥 ${r} KB | Buffer: ${playQueue.length} | Reconnects: ${reconnects}`;
218
- }, 1000);
 
 
 
 
219
 
220
- // Keep-alive: ping server biar gak sleep
221
- setInterval(() => fetch('/ping').catch(()=>{}), 60000);
222
- setInterval(() => {
223
- if (ws && ws.readyState === 1) ws.send(new Uint8Array(1));
224
- }, 30000);
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
- // ========== UTILS ==========
228
- function toggleMute() {
229
- isMuted = !isMuted;
230
- $('muteBtn').textContent = isMuted ? '🔇 Mic OFF' : '🎤 Mic ON';
231
- $('muteBtn').classList.toggle('muted', isMuted);
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
 
234
- function setMeter(id, rms) {
235
- $(id).style.width = Math.min(100, rms * 500) + '%';
 
 
 
 
236
  }
237
 
238
- function setStatus(t) { $('status').textContent = t; }
 
 
 
 
 
 
 
 
 
 
 
239
 
240
  async function tryWakeLock() {
241
- try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); }
242
- catch(e) {}
243
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  </script>
245
  </body>
246
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <title>Audio CCTV</title>
10
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙</text></svg>">
11
+ <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
12
  <style>
13
+ :root {
14
+ --bg: #050505;
15
+ --surface: #0d0d0d;
16
+ --surface-2: #141414;
17
+ --surface-3: #1a1a1a;
18
+ --border: #1f1f1f;
19
+ --border-2: #2a2a2a;
20
+ --text: #f0f0f0;
21
+ --text-2: #999;
22
+ --text-3: #555;
23
+ --accent: #00e676;
24
+ --accent-dim: rgba(0,230,118,.08);
25
+ --accent-glow: rgba(0,230,118,.15);
26
+ --danger: #ff3d57;
27
+ --danger-dim: rgba(255,61,87,.08);
28
+ --blue: #448aff;
29
+ --blue-dim: rgba(68,138,255,.15);
30
+ --radius: 14px;
31
+ --radius-sm: 10px;
32
+ --transition: .25s cubic-bezier(.4,0,.2,1);
33
+ }
34
+
35
+ * { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
36
+
37
+ html, body {
38
+ height:100%; overflow:hidden;
39
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
40
+ background: var(--bg); color: var(--text);
41
+ -webkit-font-smoothing: antialiased;
42
+ }
43
+
44
  body {
45
+ display:flex; justify-content:center; align-items:center;
46
+ }
47
+
48
+ .app {
49
+ width:100%; max-width:400px; height:100vh; max-height:100dvh;
50
+ display:flex; flex-direction:column;
51
+ padding: 0 20px;
52
+ overflow-y:auto; overflow-x:hidden;
53
+ scrollbar-width:none;
54
+ }
55
+ .app::-webkit-scrollbar { display:none; }
56
+
57
+ /* ===== HEADER ===== */
58
+ .header {
59
+ padding: 52px 0 28px;
60
+ text-align:center; flex-shrink:0;
61
+ }
62
+ .header-icon {
63
+ width:52px; height:52px;
64
+ background: var(--accent-dim);
65
+ border: 1px solid rgba(0,230,118,.12);
66
+ border-radius:16px;
67
+ display:inline-flex; align-items:center; justify-content:center;
68
+ margin-bottom:16px;
69
+ }
70
+ .header-icon i { color: var(--accent); }
71
+ .header h1 {
72
+ font-size:22px; font-weight:700;
73
+ letter-spacing:-.3px; margin-bottom:4px;
74
+ }
75
+ .header p { font-size:13px; color:var(--text-3); }
76
+
77
+ /* ===== CARD ===== */
78
+ .card {
79
+ background: var(--surface);
80
+ border: 1px solid var(--border);
81
+ border-radius: var(--radius);
82
+ padding:20px; margin-bottom:12px;
83
+ flex-shrink:0;
84
+ }
85
+ .card-label {
86
+ font-size:11px; font-weight:600;
87
+ text-transform:uppercase; letter-spacing:.8px;
88
+ color:var(--text-3); margin-bottom:14px;
89
+ display:flex; align-items:center; gap:6px;
90
+ }
91
+ .card-label i { width:14px; height:14px; }
92
+
93
+ /* ===== ROOM INPUT ===== */
94
+ .room-row { display:flex; gap:10px; }
95
+ .room-input {
96
+ flex:1; padding:14px 16px;
97
+ background: var(--surface-2);
98
+ border:1.5px solid var(--border);
99
+ border-radius: var(--radius-sm);
100
+ color: var(--text); font-size:15px;
101
+ outline:none; transition: var(--transition);
102
+ }
103
+ .room-input:focus {
104
+ border-color: var(--accent);
105
+ background: var(--accent-dim);
106
+ }
107
+ .room-input::placeholder { color: var(--text-3); }
108
+ .room-input:disabled {
109
+ opacity:.4; pointer-events:none;
110
+ }
111
+
112
+ /* ===== BUTTONS ===== */
113
  .btn {
114
+ display:inline-flex; align-items:center; justify-content:center; gap:8px;
115
+ padding:14px 20px; border:none; border-radius:var(--radius-sm);
116
+ font-size:14px; font-weight:600; cursor:pointer;
117
+ transition: var(--transition); position:relative; overflow:hidden;
118
+ flex-shrink:0;
119
+ }
120
+ .btn:active { transform:scale(.96); }
121
+ .btn i { width:18px; height:18px; flex-shrink:0; pointer-events:none; }
122
+ .btn:disabled { opacity:.35; pointer-events:none; transform:none; }
123
+
124
+ .btn-connect {
125
+ background: var(--accent); color:#000;
126
+ }
127
+ .btn-connect:hover { background:#00f082; }
128
+ .btn-connect.connected {
129
+ background: var(--danger-dim); color: var(--danger);
130
+ border: 1px solid rgba(255,61,87,.2);
131
+ }
132
+
133
+ .btn-row { display:flex; gap:10px; }
134
+ .btn-mic {
135
+ flex:1; background: var(--accent-dim); color: var(--accent);
136
+ border:1px solid rgba(0,230,118,.15);
137
+ }
138
+ .btn-mic.muted {
139
+ background: var(--danger-dim); color: var(--danger);
140
+ border-color: rgba(255,61,87,.2);
141
+ }
142
+ .btn-speaker {
143
+ flex:1; background: var(--blue-dim); color: var(--blue);
144
+ border:1px solid rgba(68,138,255,.15);
145
+ }
146
+ .btn-speaker.muted {
147
+ background: var(--danger-dim); color: var(--danger);
148
+ border-color: rgba(255,61,87,.2);
149
+ }
150
+
151
+ /* ===== STATUS ===== */
152
+ .status-bar {
153
+ display:flex; align-items:center; gap:10px;
154
+ padding:14px 16px; border-radius:var(--radius-sm);
155
+ background: var(--surface-2); border:1px solid var(--border);
156
+ }
157
+ .status-dot {
158
+ width:8px; height:8px; border-radius:50%;
159
+ background: var(--text-3); flex-shrink:0;
160
+ transition: var(--transition);
161
+ }
162
+ .status-dot.live {
163
+ background: var(--accent);
164
+ box-shadow: 0 0 8px var(--accent-glow);
165
+ animation: pulse-dot 2s infinite;
166
+ }
167
+ .status-dot.error { background: var(--danger); }
168
+
169
+ @keyframes pulse-dot {
170
+ 0%,100% { opacity:1; }
171
+ 50% { opacity:.4; }
172
+ }
173
+ .status-text { font-size:13px; color:var(--text-2); flex:1; }
174
+
175
+ /* ===== METERS ===== */
176
+ .meters { display:flex; flex-direction:column; gap:16px; }
177
+ .meter-item {}
178
+ .meter-head {
179
+ display:flex; align-items:center; justify-content:space-between;
180
+ margin-bottom:8px;
181
+ }
182
+ .meter-label {
183
+ display:flex; align-items:center; gap:6px;
184
+ font-size:12px; font-weight:600; color:var(--text-2);
185
+ }
186
+ .meter-label i { width:14px; height:14px; }
187
+ .meter-val {
188
+ font-size:11px; color:var(--text-3);
189
+ font-variant-numeric: tabular-nums;
190
+ }
191
+ .meter-track {
192
+ width:100%; height:4px;
193
+ background: var(--surface-3);
194
+ border-radius:2px; overflow:hidden;
195
  }
196
  .meter-fill {
197
+ height:100%; width:0%;
198
+ border-radius:2px;
199
+ transition: width .06s linear;
200
  }
201
+ .meter-fill.mic { background: var(--accent); }
202
+ .meter-fill.spk { background: var(--blue); }
203
+
204
+ /* ===== STATS ===== */
205
+ .stats-grid {
206
+ display:grid; grid-template-columns:1fr 1fr 1fr;
207
+ gap:10px;
208
+ }
209
+ .stat-item {
210
+ text-align:center; padding:12px 8px;
211
+ background: var(--surface-2);
212
+ border-radius:var(--radius-sm);
213
+ border:1px solid var(--border);
214
+ }
215
+ .stat-val {
216
+ font-size:16px; font-weight:700;
217
+ font-variant-numeric: tabular-nums;
218
+ margin-bottom:2px;
219
  }
220
+ .stat-label { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.5px; }
221
+ .stat-val.up { color: var(--accent); }
222
+ .stat-val.down { color: var(--blue); }
223
+ .stat-val.neutral { color: var(--text-2); }
224
+
225
+ /* ===== TIPS ===== */
226
  .tips {
227
+ padding:16px; background:var(--surface-2);
228
+ border:1px solid var(--border);
229
+ border-radius:var(--radius-sm);
230
+ margin-bottom:32px;
231
+ }
232
+ .tips-title {
233
+ font-size:12px; font-weight:600; color:var(--text-2);
234
+ margin-bottom:10px; display:flex; align-items:center; gap:6px;
235
+ }
236
+ .tips-title i { width:14px; height:14px; }
237
+ .tips ul {
238
+ list-style:none; display:flex; flex-direction:column; gap:6px;
239
+ }
240
+ .tips li {
241
+ font-size:12px; color:var(--text-3); line-height:1.5;
242
+ padding-left:16px; position:relative;
243
+ }
244
+ .tips li::before {
245
+ content:''; position:absolute; left:0; top:7px;
246
+ width:4px; height:4px; border-radius:50%;
247
+ background: var(--border-2);
248
+ }
249
+
250
+ /* ===== CONTROLS HIDDEN UNTIL CONNECTED ===== */
251
+ .controls { display:none; }
252
+ .controls.show { display:flex; flex-direction:column; gap:12px; }
253
+
254
+ /* ===== RESPONSIVE ===== */
255
+ @media (min-width:768px) {
256
+ .app {
257
+ max-height:unset; justify-content:center;
258
+ padding: 40px 20px;
259
+ }
260
+ .header { padding-top:0; }
261
+ }
262
+
263
+ @media (max-height:640px) {
264
+ .header { padding: 24px 0 16px; }
265
+ .header-icon { width:40px; height:40px; border-radius:12px; margin-bottom:10px; }
266
+ .tips { display:none; }
267
  }
 
268
  </style>
269
  </head>
270
  <body>
271
+ <div class="app">
272
+
273
+ <!-- HEADER -->
274
+ <div class="header">
275
+ <div class="header-icon">
276
+ <i data-lucide="radio" style="width:24px;height:24px;"></i>
277
+ </div>
278
+ <h1>Audio CCTV</h1>
279
+ <p>Intercom 2 arah &mdash; realtime audio relay</p>
280
+ </div>
281
 
282
+ <!-- ROOM + CONNECT -->
283
+ <div class="card">
284
+ <div class="card-label">
285
+ <i data-lucide="hash"></i> Room
286
+ </div>
287
+ <div class="room-row">
288
+ <input type="text" class="room-input" id="room" placeholder="Nama room..." value="kamar" autocomplete="off" spellcheck="false">
289
+ <button class="btn btn-connect" id="connectBtn">
290
+ <i data-lucide="plug"></i>
291
+ </button>
292
+ </div>
293
+ </div>
294
+
295
+ <!-- STATUS -->
296
+ <div class="card">
297
+ <div class="status-bar">
298
+ <div class="status-dot" id="statusDot"></div>
299
+ <span class="status-text" id="statusText">Belum terhubung</span>
300
+ </div>
301
+ </div>
302
 
303
+ <!-- CONTROLS (hidden until connected) -->
304
+ <div class="controls" id="controls">
305
 
306
+ <!-- MIC & SPEAKER -->
307
+ <div class="card">
308
+ <div class="card-label">
309
+ <i data-lucide="settings-2"></i> Kontrol
310
+ </div>
311
+ <div class="btn-row">
312
+ <button class="btn btn-mic" id="muteBtn">
313
+ <i data-lucide="mic"></i>
314
+ <span>Mic ON</span>
315
+ </button>
316
+ <button class="btn btn-speaker" id="spkBtn">
317
+ <i data-lucide="volume-2"></i>
318
+ <span>Speaker ON</span>
319
+ </button>
320
+ </div>
321
+ </div>
322
 
323
+ <!-- METERS -->
324
+ <div class="card">
325
+ <div class="card-label">
326
+ <i data-lucide="activity"></i> Level
327
+ </div>
328
+ <div class="meters">
329
+ <div class="meter-item">
330
+ <div class="meter-head">
331
+ <div class="meter-label"><i data-lucide="mic"></i> Mikrofon</div>
332
+ <span class="meter-val" id="micDb">-∞ dB</span>
333
+ </div>
334
+ <div class="meter-track"><div class="meter-fill mic" id="micFill"></div></div>
335
+ </div>
336
+ <div class="meter-item">
337
+ <div class="meter-head">
338
+ <div class="meter-label"><i data-lucide="volume-2"></i> Speaker</div>
339
+ <span class="meter-val" id="spkDb">-∞ dB</span>
340
+ </div>
341
+ <div class="meter-track"><div class="meter-fill spk" id="spkFill"></div></div>
342
+ </div>
343
+ </div>
344
+ </div>
345
 
346
+ <!-- STATS -->
347
+ <div class="card">
348
+ <div class="card-label">
349
+ <i data-lucide="bar-chart-3"></i> Statistik
350
+ </div>
351
+ <div class="stats-grid">
352
+ <div class="stat-item">
353
+ <div class="stat-val up" id="statUp">0</div>
354
+ <div class="stat-label">Sent KB</div>
355
+ </div>
356
+ <div class="stat-item">
357
+ <div class="stat-val down" id="statDown">0</div>
358
+ <div class="stat-label">Recv KB</div>
359
+ </div>
360
+ <div class="stat-item">
361
+ <div class="stat-val neutral" id="statBuf">0</div>
362
+ <div class="stat-label">Buffer</div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ </div>
367
 
368
+ <!-- TIPS -->
369
+ <div class="tips" id="tips">
370
+ <div class="tips-title">
371
+ <i data-lucide="lightbulb"></i> Tips HP bekas 24 jam
372
+ </div>
373
+ <ul>
374
+ <li>Colok charger terus, baterai aman di 80%</li>
375
+ <li>Display &gt; Screen timeout &gt; Never</li>
376
+ <li>Battery optimization &gt; Chrome &gt; Don't optimize</li>
377
+ <li>WiFi &gt; Keep on during sleep &gt; Always</li>
378
+ <li>Aktifkan Do Not Disturb mode</li>
379
+ </ul>
380
  </div>
381
+
382
  </div>
383
 
384
  <script>
385
  const $ = id => document.getElementById(id);
386
+ const BUFFER_SIZE = 4096;
387
+ const SAMPLE_RATE = 16000;
388
 
389
  let ws, audioCtx, micStream;
390
  let captureNode, playNode, sourceNode, silentGain;
391
  let playQueue = [];
392
+ let isMuted = false, isSpkMuted = false, isConnected = false;
393
  let bytesSent = 0, bytesRecv = 0, reconnects = 0;
394
+ let reconnectTimer = null;
395
+
396
+ // ========== INIT LUCIDE ==========
397
+ document.addEventListener('DOMContentLoaded', () => { lucide.createIcons(); });
398
 
399
+ // ========== CONNECT / DISCONNECT ==========
400
+ $('connectBtn').onclick = () => isConnected ? disconnect() : connect();
 
401
 
402
+ async function connect() {
403
  try {
404
+ micStream = await navigator.mediaDevices.getUserMedia({
405
+ audio: {
406
+ echoCancellation: true,
407
+ noiseSuppression: true,
408
+ autoGainControl: true,
409
+ sampleRate: SAMPLE_RATE
410
+ }
411
+ });
412
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
413
+ if (audioCtx.state === 'suspended') await audioCtx.resume();
414
+ setupAudioNodes();
 
 
 
415
  connectWS();
416
  } catch(e) {
417
+ setStatus('error', 'Izinkan akses mikrofon');
 
418
  }
419
  }
420
 
421
+ function disconnect() {
422
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
423
+ if (ws) { ws.onclose = null; ws.close(); ws = null; }
424
+ if (captureNode) { captureNode.disconnect(); captureNode = null; }
425
+ if (playNode) { playNode.disconnect(); playNode = null; }
426
+ if (sourceNode) { sourceNode.disconnect(); sourceNode = null; }
427
+ if (silentGain) { silentGain.disconnect(); silentGain = null; }
428
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
429
+ if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; }
430
+ playQueue = [];
431
+ isConnected = false;
432
+ setStatus('off', 'Terputus');
433
+ $('controls').classList.remove('show');
434
+ $('connectBtn').classList.remove('connected');
435
+ $('connectBtn').innerHTML = '<i data-lucide="plug"></i>';
436
+ $('room').disabled = false;
437
+ lucide.createIcons({ nodes: [$('connectBtn')] });
438
+ }
439
+
440
  // ========== WEBSOCKET ==========
441
  function connectWS() {
442
+ const room = $('room').value.trim() || 'default';
443
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
444
  ws = new WebSocket(`${proto}//${location.host}/ws/${room}`);
445
  ws.binaryType = 'arraybuffer';
446
 
447
  ws.onopen = () => {
448
+ isConnected = true;
449
+ setStatus('live', `Live &mdash; ${room}`);
450
+ $('controls').classList.add('show');
451
+ $('connectBtn').classList.add('connected');
452
+ $('connectBtn').innerHTML = '<i data-lucide="plug-zap"></i>';
453
+ $('room').disabled = true;
454
+ lucide.createIcons({ nodes: [$('connectBtn')] });
455
  tryWakeLock();
456
  };
457
 
 
466
  };
467
 
468
  ws.onclose = () => {
469
+ if (!isConnected) return;
470
  reconnects++;
471
+ setStatus('error', `Reconnecting #${reconnects}...`);
472
+ reconnectTimer = setTimeout(connectWS, 3000);
473
  };
474
 
475
+ ws.onerror = () => {};
476
  }
477
 
478
+ // ========== AUDIO PIPELINE ==========
479
+ function setupAudioNodes() {
 
480
  sourceNode = audioCtx.createMediaStreamSource(micStream);
481
+ captureNode = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
482
  silentGain = audioCtx.createGain();
483
+ silentGain.gain.value = 0;
484
 
485
  captureNode.onaudioprocess = (e) => {
486
+ if (!ws || ws.readyState !== 1 || isMuted) { setMeter('mic', 0); return; }
487
  const pcm = e.inputBuffer.getChannelData(0);
488
+ const rms = calcRMS(pcm);
489
+ setMeter('mic', rms);
490
+ const int16 = float32ToInt16(pcm);
 
 
 
 
 
 
 
 
 
491
  ws.send(int16.buffer);
492
  bytesSent += int16.buffer.byteLength;
493
  };
 
496
  captureNode.connect(silentGain);
497
  silentGain.connect(audioCtx.destination);
498
 
499
+ playNode = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
 
 
500
  playNode.onaudioprocess = (e) => {
501
  const out = e.outputBuffer.getChannelData(0);
502
+ if (playQueue.length > 0 && !isSpkMuted) {
503
  const chunk = playQueue.shift();
504
  const len = Math.min(out.length, chunk.length);
505
  for (let i = 0; i < len; i++) out[i] = chunk[i];
506
  for (let i = len; i < out.length; i++) out[i] = 0;
507
+ setMeter('spk', calcRMS(chunk));
 
 
508
  } else {
509
  out.fill(0);
510
+ setMeter('spk', 0);
511
  }
512
  };
513
 
514
  playNode.connect(audioCtx.destination);
515
+ startStatsLoop();
516
+ startKeepAlive();
517
+ }
518
 
519
+ // ========== CONTROLS ==========
520
+ $('muteBtn').onclick = () => {
521
+ isMuted = !isMuted;
522
+ const btn = $('muteBtn');
523
+ btn.classList.toggle('muted', isMuted);
524
+ btn.innerHTML = isMuted
525
+ ? '<i data-lucide="mic-off"></i><span>Mic OFF</span>'
526
+ : '<i data-lucide="mic"></i><span>Mic ON</span>';
527
+ lucide.createIcons({ nodes: [btn] });
528
+ };
529
 
530
+ $('spkBtn').onclick = () => {
531
+ isSpkMuted = !isSpkMuted;
532
+ const btn = $('spkBtn');
533
+ btn.classList.toggle('muted', isSpkMuted);
534
+ btn.innerHTML = isSpkMuted
535
+ ? '<i data-lucide="volume-x"></i><span>Speaker OFF</span>'
536
+ : '<i data-lucide="volume-2"></i><span>Speaker ON</span>';
537
+ lucide.createIcons({ nodes: [btn] });
538
+ };
539
+
540
+ // ========== HELPERS ==========
541
+ function float32ToInt16(f32) {
542
+ const int16 = new Int16Array(f32.length);
543
+ for (let i = 0; i < f32.length; i++) {
544
+ const s = Math.max(-1, Math.min(1, f32[i]));
545
+ int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
546
+ }
547
+ return int16;
548
  }
549
 
550
+ function calcRMS(buf) {
551
+ let sum = 0;
552
+ for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
553
+ return Math.sqrt(sum / buf.length);
554
+ }
555
+
556
+ function setMeter(type, rms) {
557
+ const pct = Math.min(100, rms * 500);
558
+ const db = rms > 0.0001 ? (20 * Math.log10(rms)).toFixed(0) : '-∞';
559
+ if (type === 'mic') {
560
+ $('micFill').style.width = pct + '%';
561
+ $('micDb').textContent = db + ' dB';
562
+ } else {
563
+ $('spkFill').style.width = pct + '%';
564
+ $('spkDb').textContent = db + ' dB';
565
+ }
566
  }
567
 
568
+ function setStatus(state, text) {
569
+ const dot = $('statusDot');
570
+ dot.className = 'status-dot';
571
+ if (state === 'live') dot.classList.add('live');
572
+ if (state === 'error') dot.classList.add('error');
573
+ $('statusText').innerHTML = text;
574
  }
575
 
576
+ function startStatsLoop() {
577
+ setInterval(() => {
578
+ $('statUp').textContent = (bytesSent / 1024).toFixed(0);
579
+ $('statDown').textContent = (bytesRecv / 1024).toFixed(0);
580
+ $('statBuf').textContent = playQueue.length;
581
+ }, 1000);
582
+ }
583
+
584
+ function startKeepAlive() {
585
+ setInterval(() => fetch('/ping').catch(() => {}), 55000);
586
+ setInterval(() => { if (ws && ws.readyState === 1) ws.send(new Uint8Array(1)); }, 25000);
587
+ }
588
 
589
  async function tryWakeLock() {
590
+ try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch(e) {}
 
591
  }
592
+
593
+ // ========== ALWAYS ON: Re-connect on visibility change ==========
594
+ document.addEventListener('visibilitychange', () => {
595
+ if (document.visibilityState === 'visible' && isConnected) {
596
+ if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
597
+ if (!ws || ws.readyState > 1) connectWS();
598
+ }
599
+ });
600
+
601
+ // ========== PREVENT SLEEP: Silent audio loop ==========
602
+ (function keepAudioAlive() {
603
+ document.addEventListener('click', function once() {
604
+ try {
605
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
606
+ const osc = ctx.createOscillator();
607
+ const gain = ctx.createGain();
608
+ gain.gain.value = 0.00001;
609
+ osc.connect(gain);
610
+ gain.connect(ctx.destination);
611
+ osc.start();
612
+ } catch(e) {}
613
+ document.removeEventListener('click', once);
614
+ });
615
+ })();
616
+
617
+ // ========== NO SLEEP: Invisible video trick ==========
618
+ (function noSleep() {
619
+ let video = document.createElement('video');
620
+ video.setAttribute('playsinline','');
621
+ video.setAttribute('muted','');
622
+ video.style.cssText = 'position:fixed;top:-1px;left:-1px;width:1px;height:1px;opacity:0.01;';
623
+ const blob = new Blob([new Uint8Array([
624
+ 0,0,0,28,102,116,121,112,105,115,111,109,0,0,2,0,105,115,111,109,105,115,111,50,
625
+ 109,112,52,49,0,0,0,8,102,114,101,101,0,0,2,239,109,100,97,116,0,0,0,0
626
+ ])], {type:'video/mp4'});
627
+ video.src = URL.createObjectURL(blob);
628
+ document.body.appendChild(video);
629
+ document.addEventListener('click', function() { video.play().catch(()=>{}); }, {once:true});
630
+ })();
631
  </script>
632
  </body>
633
  </html>