mgokg commited on
Commit
7a4cba8
·
verified ·
1 Parent(s): f43c677

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +548 -0
index.html ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Gemini Voice Chat</title>
8
+ <style>
9
+ :root {
10
+ --color-accent: #6366f1;
11
+ --color-background: #0f172a;
12
+ --color-surface: #1e293b;
13
+ --color-text: #e2e8f0;
14
+ --boxSize: 8px;
15
+ --gutter: 4px;
16
+ }
17
+ body {
18
+ margin: 0;
19
+ padding: 0;
20
+ background-color: var(--color-background);
21
+ color: var(--color-text);
22
+ font-family: system-ui, -apple-system, sans-serif;
23
+ min-height: 100vh;
24
+ display: flex;
25
+ flex-direction: column;
26
+ align-items: center;
27
+ justify-content: center;
28
+ }
29
+ .container {
30
+ width: 90%;
31
+ max-width: 800px;
32
+ background-color: var(--color-surface);
33
+ padding: 2rem;
34
+ border-radius: 1rem;
35
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
36
+ }
37
+ .wave-container {
38
+ position: relative;
39
+ display: flex;
40
+ min-height: 100px;
41
+ max-height: 128px;
42
+ justify-content: center;
43
+ align-items: center;
44
+ margin: 2rem 0;
45
+ }
46
+ .box-container {
47
+ display: flex;
48
+ justify-content: space-between;
49
+ height: 64px;
50
+ width: 100%;
51
+ }
52
+ .box {
53
+ height: 100%;
54
+ width: var(--boxSize);
55
+ background: var(--color-accent);
56
+ border-radius: 8px;
57
+ transition: transform 0.05s ease;
58
+ }
59
+ .controls {
60
+ display: grid;
61
+ gap: 1rem;
62
+ margin-bottom: 2rem;
63
+ }
64
+ .input-group {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 0.5rem;
68
+ }
69
+ label {
70
+ font-size: 0.875rem;
71
+ font-weight: 500;
72
+ }
73
+ input,
74
+ select,
75
+ textarea {
76
+ padding: 0.75rem;
77
+ border-radius: 0.5rem;
78
+ border: 1px solid rgba(255, 255, 255, 0.1);
79
+ background-color: var(--color-background);
80
+ color: var(--color-text);
81
+ font-size: 1rem;
82
+ font-family: inherit;
83
+ }
84
+ textarea {
85
+ resize: vertical;
86
+ min-height: 80px;
87
+ }
88
+ button {
89
+ padding: 1rem 2rem;
90
+ border-radius: 0.5rem;
91
+ border: none;
92
+ background-color: var(--color-accent);
93
+ color: white;
94
+ font-weight: 600;
95
+ cursor: pointer;
96
+ transition: all 0.2s ease;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ gap: 12px;
101
+ min-width: 180px;
102
+ }
103
+ button:hover {
104
+ opacity: 0.9;
105
+ transform: translateY(-1px);
106
+ }
107
+ .icon-with-spinner {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ gap: 12px;
112
+ min-width: 180px;
113
+ }
114
+ .spinner {
115
+ width: 20px;
116
+ height: 20px;
117
+ border: 2px solid white;
118
+ border-top-color: transparent;
119
+ border-radius: 50%;
120
+ animation: spin 1s linear infinite;
121
+ flex-shrink: 0;
122
+ }
123
+ @keyframes spin {
124
+ to {
125
+ transform: rotate(360deg);
126
+ }
127
+ }
128
+ .pulse-container {
129
+ display: flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ gap: 12px;
133
+ }
134
+ .pulse-circle {
135
+ width: 20px;
136
+ height: 20px;
137
+ border-radius: 50%;
138
+ background-color: white;
139
+ opacity: 0.2;
140
+ flex-shrink: 0;
141
+ transform: translateX(-0%) scale(var(--audio-level, 1));
142
+ transition: transform 0.1s ease;
143
+ }
144
+ /* Add styles for toast notifications */
145
+ .toast {
146
+ position: fixed;
147
+ top: 20px;
148
+ left: 50%;
149
+ transform: translateX(-50%);
150
+ padding: 16px 24px;
151
+ border-radius: 4px;
152
+ font-size: 14px;
153
+ z-index: 1000;
154
+ display: none;
155
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
156
+ }
157
+ .toast.error {
158
+ background-color: #f44336;
159
+ color: white;
160
+ }
161
+ .toast.warning {
162
+ background-color: #ffd700;
163
+ color: black;
164
+ }
165
+ /* Add styles for the mute toggle */
166
+ .mute-toggle {
167
+ width: 24px;
168
+ height: 24px;
169
+ cursor: pointer;
170
+ flex-shrink: 0;
171
+ }
172
+ .mute-toggle svg {
173
+ display: block;
174
+ }
175
+ #start-button {
176
+ margin-left: auto;
177
+ margin-right: auto;
178
+ }
179
+ </style>
180
+ </head>
181
+
182
+ <body>
183
+ <!-- Add toast element after body opening tag -->
184
+ <div id="error-toast" class="toast"></div>
185
+ <div style="text-align: center">
186
+ <h1>Gemini Voice Chat</h1>
187
+ <p>Sprechen Sie mit Gemini über Echtzeit-Audio-Streaming</p>
188
+ <p>
189
+ Holen Sie sich einen Gemini API-Schlüssel
190
+ <a href="https://ai.google.dev/gemini-api/docs/api-key">hier</a>
191
+ </p>
192
+ </div>
193
+ <div class="container">
194
+ <div class="controls">
195
+ <div class="input-group">
196
+ <label for="voice">Stimme</label>
197
+ <select id="voice">
198
+ <option value="Puck">Puck</option>
199
+ <option value="Charon">Charon</option>
200
+ <option value="Kore" selected>Kore</option>
201
+ <option value="Fenrir">Fenrir</option>
202
+ <option value="Aoede">Aoede</option>
203
+ </select>
204
+ </div>
205
+ <div class="input-group">
206
+ <label for="system-message">System-Nachricht</label>
207
+ <textarea id="system-message" placeholder="Geben Sie Systemanweisungen für die KI ein...">Du bist ein hilfsamer Assistent, der Fragen beantwortet und bei verschiedenen Aufgaben hilft. Du kannst bei Bedarf auch im Internet suchen, um aktuelle Informationen zu finden.</textarea>
208
+ </div>
209
+ </div>
210
+
211
+ <div class="wave-container">
212
+ <div class="box-container">
213
+ <!-- Boxes will be dynamically added here -->
214
+ </div>
215
+ </div>
216
+
217
+ <button id="start-button">Aufnahme starten</button>
218
+ </div>
219
+
220
+ <audio id="audio-output"></audio>
221
+
222
+ <script>
223
+ let peerConnection;
224
+ let audioContext;
225
+ let dataChannel;
226
+ let isRecording = false;
227
+ let webrtc_id;
228
+ let isMuted = false;
229
+ let analyser_input, dataArray_input;
230
+ let analyser, dataArray;
231
+ let source_input = null;
232
+ let source_output = null;
233
+ const startButton = document.getElementById('start-button');
234
+ const voiceSelect = document.getElementById('voice');
235
+ const systemMessageInput = document.getElementById('system-message');
236
+ const audioOutput = document.getElementById('audio-output');
237
+ const boxContainer = document.querySelector('.box-container');
238
+ const numBars = 32;
239
+ for (let i = 0; i < numBars; i++) {
240
+ const box = document.createElement('div');
241
+ box.className = 'box';
242
+ boxContainer.appendChild(box);
243
+ }
244
+ // SVG Icons
245
+ const micIconSVG = `
246
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
247
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
248
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
249
+ <line x1="12" y1="19" x2="12" y2="23"></line>
250
+ <line x1="8" y1="23" x2="16" y2="23"></line>
251
+ </svg>`;
252
+ const micMutedIconSVG = `
253
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
254
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
255
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
256
+ <line x1="12" y1="19" x2="12" y2="23"></line>
257
+ <line x1="8" y1="23" x2="16" y2="23"></line>
258
+ <line x1="1" y1="1" x2="23" y2="23"></line>
259
+ </svg>`;
260
+ function updateButtonState() {
261
+ startButton.innerHTML = '';
262
+ startButton.onclick = null;
263
+ if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) {
264
+ startButton.innerHTML = `
265
+ <div class="icon-with-spinner">
266
+ <div class="spinner"></div>
267
+ <span>Verbinde...</span>
268
+ </div>
269
+ `;
270
+ startButton.disabled = true;
271
+ } else if (peerConnection && peerConnection.connectionState === 'connected') {
272
+ const pulseContainer = document.createElement('div');
273
+ pulseContainer.className = 'pulse-container';
274
+ pulseContainer.innerHTML = `
275
+ <div class="pulse-circle"></div>
276
+ <span>Aufnahme stoppen</span>
277
+ `;
278
+ const muteToggle = document.createElement('div');
279
+ muteToggle.className = 'mute-toggle';
280
+ muteToggle.title = isMuted ? 'Stummschaltung aufheben' : 'Stumm schalten';
281
+ muteToggle.innerHTML = isMuted ? micMutedIconSVG : micIconSVG;
282
+ muteToggle.addEventListener('click', toggleMute);
283
+ startButton.appendChild(pulseContainer);
284
+ startButton.appendChild(muteToggle);
285
+ startButton.disabled = false;
286
+ } else {
287
+ startButton.innerHTML = 'Aufnahme starten';
288
+ startButton.disabled = false;
289
+ }
290
+ }
291
+ function showError(message) {
292
+ const toast = document.getElementById('error-toast');
293
+ toast.textContent = message;
294
+ toast.className = 'toast error';
295
+ toast.style.display = 'block';
296
+ // Hide toast after 5 seconds
297
+ setTimeout(() => {
298
+ toast.style.display = 'none';
299
+ }, 5000);
300
+ }
301
+ function toggleMute(event) {
302
+ event.stopPropagation();
303
+ if (!peerConnection || peerConnection.connectionState !== 'connected') return;
304
+ isMuted = !isMuted;
305
+ console.log("Mute toggled:", isMuted);
306
+ peerConnection.getSenders().forEach(sender => {
307
+ if (sender.track && sender.track.kind === 'audio') {
308
+ sender.track.enabled = !isMuted;
309
+ console.log(`Audio track ${sender.track.id} enabled: ${!isMuted}`);
310
+ }
311
+ });
312
+ updateButtonState();
313
+ }
314
+ async function setupWebRTC() {
315
+ const config = __RTC_CONFIGURATION__;
316
+ peerConnection = new RTCPeerConnection(config);
317
+ webrtc_id = Math.random().toString(36).substring(7);
318
+ const timeoutId = setTimeout(() => {
319
+ const toast = document.getElementById('error-toast');
320
+ toast.textContent = "Die Verbindung dauert länger als gewöhnlich. Verwenden Sie ein VPN?";
321
+ toast.className = 'toast warning';
322
+ toast.style.display = 'block';
323
+ // Hide warning after 5 seconds
324
+ setTimeout(() => {
325
+ toast.style.display = 'none';
326
+ }, 5000);
327
+ }, 5000);
328
+ try {
329
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
330
+ stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
331
+ if (!audioContext || audioContext.state === 'closed') {
332
+ audioContext = new AudioContext();
333
+ }
334
+ if (source_input) {
335
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting previous input source:", e); }
336
+ source_input = null;
337
+ }
338
+ source_input = audioContext.createMediaStreamSource(stream);
339
+ analyser_input = audioContext.createAnalyser();
340
+ source_input.connect(analyser_input);
341
+ analyser_input.fftSize = 64;
342
+ dataArray_input = new Uint8Array(analyser_input.frequencyBinCount);
343
+ updateAudioLevel();
344
+ peerConnection.addEventListener('connectionstatechange', () => {
345
+ console.log('connectionstatechange', peerConnection.connectionState);
346
+ if (peerConnection.connectionState === 'connected') {
347
+ clearTimeout(timeoutId);
348
+ const toast = document.getElementById('error-toast');
349
+ toast.style.display = 'none';
350
+ if (analyser_input) updateAudioLevel();
351
+ if (analyser) updateVisualization();
352
+ } else if (['disconnected', 'failed', 'closed'].includes(peerConnection.connectionState)) {
353
+ // Explicitly stop animations if connection drops unexpectedly
354
+ // Note: stopWebRTC() handles the normal stop case
355
+ }
356
+ updateButtonState();
357
+ });
358
+ peerConnection.onicecandidate = ({ candidate }) => {
359
+ if (candidate) {
360
+ console.debug("Sending ICE candidate", candidate);
361
+ fetch('/webrtc/offer', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({
365
+ candidate: candidate.toJSON(),
366
+ webrtc_id: webrtc_id,
367
+ type: "ice-candidate",
368
+ })
369
+ })
370
+ }
371
+ };
372
+ peerConnection.addEventListener('track', (evt) => {
373
+ if (evt.track.kind === 'audio' && audioOutput) {
374
+ if (audioOutput.srcObject !== evt.streams[0]) {
375
+ audioOutput.srcObject = evt.streams[0];
376
+ audioOutput.play().catch(e => console.error("Audio play failed:", e));
377
+ if (!audioContext || audioContext.state === 'closed') {
378
+ console.warn("AudioContext not ready for output track analysis.");
379
+ return;
380
+ }
381
+ if (source_output) {
382
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting previous output source:", e); }
383
+ source_output = null;
384
+ }
385
+ source_output = audioContext.createMediaStreamSource(evt.streams[0]);
386
+ analyser = audioContext.createAnalyser();
387
+ source_output.connect(analyser);
388
+ analyser.fftSize = 2048;
389
+ dataArray = new Uint8Array(analyser.frequencyBinCount);
390
+ updateVisualization();
391
+ }
392
+ }
393
+ });
394
+ dataChannel = peerConnection.createDataChannel('text');
395
+ dataChannel.onmessage = (event) => {
396
+ const eventJson = JSON.parse(event.data);
397
+ if (eventJson.type === "error") {
398
+ showError(eventJson.message);
399
+ } else if (eventJson.type === "send_input") {
400
+ fetch('/input_hook', {
401
+ method: 'POST',
402
+ headers: {
403
+ 'Content-Type': 'application/json',
404
+ },
405
+ body: JSON.stringify({
406
+ webrtc_id: webrtc_id,
407
+ api_key: '', // Empty since API key is handled by Python backend
408
+ voice_name: voiceSelect.value,
409
+ system_message: systemMessageInput.value
410
+ })
411
+ });
412
+ }
413
+ };
414
+ const offer = await peerConnection.createOffer();
415
+ await peerConnection.setLocalDescription(offer);
416
+ const response = await fetch('/webrtc/offer', {
417
+ method: 'POST',
418
+ headers: { 'Content-Type': 'application/json' },
419
+ body: JSON.stringify({
420
+ sdp: peerConnection.localDescription.sdp,
421
+ type: peerConnection.localDescription.type,
422
+ webrtc_id: webrtc_id,
423
+ })
424
+ });
425
+ const serverResponse = await response.json();
426
+ if (serverResponse.status === 'failed') {
427
+ showError(serverResponse.meta.error === 'concurrency_limit_reached'
428
+ ? `Zu viele Verbindungen. Maximale Anzahl ist ${serverResponse.meta.limit}`
429
+ : serverResponse.meta.error);
430
+ stopWebRTC();
431
+ startButton.textContent = 'Aufnahme starten';
432
+ return;
433
+ }
434
+ await peerConnection.setRemoteDescription(serverResponse);
435
+ } catch (err) {
436
+ clearTimeout(timeoutId);
437
+ console.error('Error setting up WebRTC:', err);
438
+ showError('Verbindung konnte nicht hergestellt werden. Bitte versuchen Sie es erneut.');
439
+ stopWebRTC();
440
+ startButton.textContent = 'Aufnahme starten';
441
+ }
442
+ }
443
+ function updateVisualization() {
444
+ if (!analyser || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
445
+ const bars = document.querySelectorAll('.box');
446
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
447
+ return;
448
+ }
449
+ analyser.getByteFrequencyData(dataArray);
450
+ const bars = document.querySelectorAll('.box');
451
+ for (let i = 0; i < bars.length; i++) {
452
+ const barHeight = (dataArray[i] / 255) * 2;
453
+ bars[i].style.transform = `scaleY(${Math.max(0.1, barHeight)})`;
454
+ }
455
+ requestAnimationFrame(updateVisualization);
456
+ }
457
+ function updateAudioLevel() {
458
+ if (!analyser_input || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
459
+ const pulseCircle = document.querySelector('.pulse-circle');
460
+ if (pulseCircle) {
461
+ pulseCircle.style.setProperty('--audio-level', 1);
462
+ }
463
+ return;
464
+ }
465
+ analyser_input.getByteFrequencyData(dataArray_input);
466
+ const average = Array.from(dataArray_input).reduce((a, b) => a + b, 0) / dataArray_input.length;
467
+ const audioLevel = average / 255;
468
+ const pulseCircle = document.querySelector('.pulse-circle');
469
+ if (pulseCircle) {
470
+ pulseCircle.style.setProperty('--audio-level', 1 + audioLevel);
471
+ }
472
+ requestAnimationFrame(updateAudioLevel);
473
+ }
474
+ function stopWebRTC() {
475
+ console.log("Running stopWebRTC");
476
+ if (peerConnection) {
477
+ peerConnection.getSenders().forEach(sender => {
478
+ if (sender.track) {
479
+ sender.track.stop();
480
+ }
481
+ });
482
+ peerConnection.ontrack = null;
483
+ peerConnection.onicegatheringstatechange = null;
484
+ peerConnection.onconnectionstatechange = null;
485
+ if (dataChannel) {
486
+ dataChannel.onmessage = null;
487
+ try { dataChannel.close(); } catch (e) { console.warn("Error closing data channel:", e); }
488
+ dataChannel = null;
489
+ }
490
+ try { peerConnection.close(); } catch (e) { console.warn("Error closing peer connection:", e); }
491
+ peerConnection = null;
492
+ }
493
+ if (audioOutput) {
494
+ audioOutput.pause();
495
+ audioOutput.srcObject = null;
496
+ }
497
+ if (source_input) {
498
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting input source:", e); }
499
+ source_input = null;
500
+ }
501
+ if (source_output) {
502
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting output source:", e); }
503
+ source_output = null;
504
+ }
505
+ if (audioContext && audioContext.state !== 'closed') {
506
+ audioContext.close().then(() => {
507
+ console.log("AudioContext closed successfully.");
508
+ audioContext = null;
509
+ }).catch(e => {
510
+ console.error("Error closing AudioContext:", e);
511
+ audioContext = null;
512
+ });
513
+ } else {
514
+ audioContext = null;
515
+ }
516
+ analyser_input = null;
517
+ dataArray_input = null;
518
+ analyser = null;
519
+ dataArray = null;
520
+ isMuted = false;
521
+ isRecording = false;
522
+ updateButtonState();
523
+ const bars = document.querySelectorAll('.box');
524
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
525
+ const pulseCircle = document.querySelector('.pulse-circle');
526
+ if (pulseCircle) {
527
+ pulseCircle.style.setProperty('--audio-level', 1);
528
+ }
529
+ }
530
+ startButton.addEventListener('click', (event) => {
531
+ if (event.target.closest('.mute-toggle')) {
532
+ return;
533
+ }
534
+ if (peerConnection && peerConnection.connectionState === 'connected') {
535
+ console.log("Stop button clicked");
536
+ stopWebRTC();
537
+ } else if (!peerConnection || ['new', 'closed', 'failed', 'disconnected'].includes(peerConnection.connectionState)) {
538
+ console.log("Start button clicked");
539
+ setupWebRTC();
540
+ isRecording = true;
541
+ updateButtonState();
542
+ }
543
+ });
544
+ updateButtonState();
545
+ </script>
546
+ </body>
547
+
548
+ </html>