amauricunha commited on
Commit
c8a75d1
·
verified ·
1 Parent(s): 96f9398

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +376 -323
index.html CHANGED
@@ -1,390 +1,443 @@
1
- <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>TTS Language Study Tool</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <style>
9
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
10
  body {
11
  font-family: 'Inter', sans-serif;
12
- background-color: #f7f7f7;
13
- display: flex;
14
- justify-content: center;
15
- align-items: center;
16
- min-height: 100vh;
17
- padding: 1rem;
18
  }
19
  .container {
20
- max-width: 700px;
21
- width: 100%;
22
- background-color: #ffffff;
23
- padding: 2.5rem;
24
- border-radius: 16px;
25
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
26
  }
27
- button:disabled {
28
- opacity: 0.5;
29
- cursor: not-allowed;
 
 
30
  }
31
- #audioPlayer {
32
- width: 100%;
33
- margin-top: 1.5rem;
 
34
  }
35
- select, textarea {
36
- border: 1px solid #d1d5db;
37
- border-radius: 8px;
38
- padding: 0.75rem;
 
39
  color: #1f2937;
40
- transition: border-color 0.2s, box-shadow 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }
42
- select:focus, textarea:focus {
43
- border-color: #4f46e5;
44
- box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
45
- outline: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
  </style>
48
  </head>
49
  <body>
50
- <div class="container">
51
- <h1 class="text-3xl font-bold text-gray-800 mb-6 border-b-2 pb-2 border-indigo-500">
52
- Text-to-Speech Language Study Tool
53
- </h1>
54
- <p class="text-gray-600 mb-6">
55
- Paste your English text below to practice listening comprehension and pronunciation.
56
- </p>
57
-
58
- <div class="grid grid-cols-2 gap-4 mb-4">
59
- <div>
60
- <label for="voiceSelector" class="block text-sm font-medium text-gray-700 mb-2">Select Speaker:</label>
61
- <select id="voiceSelector" class="w-full">
62
- <option value="Puck">👩 Woman (Puck - Upbeat, US)</option>
63
- <option value="Wren">👨 Man (Wren - Clear, US)</option>
64
- <option value="Aoede">🗣️ Woman (Aoede - Breezy, UK)</option>
65
- </select>
66
- </div>
67
- <div>
68
- <label for="speedSelector" class="block text-sm font-medium text-gray-700 mb-2">Playback Speed:</label>
69
- <select id="speedSelector" class="w-full">
70
- <option value="1.0">1.0x (Normal)</option>
71
- <option value="0.75">0.75x (Slower)</option>
72
- <option value="0.5">0.5x (Slowest - for comprehension)</option>
73
- <option value="1.25">1.25x (Faster)</option>
74
- <option value="1.5">1.5x (Fastest - for speed listening)</option>
75
- </select>
76
- </div>
77
- </div>
78
-
79
- <div class="mb-4">
80
- <label for="transcriptArea" class="block text-sm font-medium text-gray-700 mb-2">Paste English Text Here:</label>
81
- <textarea id="transcriptArea" rows="8" class="w-full resize-none" placeholder="Paste the text you want to practice listening to. (Max 10,000 characters)"></textarea>
82
- <div id="charCount" class="text-xs text-gray-500 mt-1 text-right">0 / 10000 Characters</div>
83
  </div>
84
 
85
- <div id="status" class="mb-4 text-center p-3 rounded-lg text-sm transition duration-300 bg-gray-100 text-gray-700">
86
- Enter text and click 'Generate & Play'.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
- <button id="generateButton"
90
- class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition duration-300 ease-in-out shadow-lg transform hover:scale-[1.01] flex items-center justify-center">
91
- <svg id="speakerIcon" class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l3.586-3.586a1 1 0 011.414 0L12 9l-3 3 3 3-1.414 1.414a1 1 0 01-1.414 0L5.586 15z"></path></svg>
92
- Generate & Play Audio
93
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- <!-- Setting controls to show the playback speed option -->
96
- <audio id="audioPlayer" controls hidden></audio>
97
-
98
- <!-- Queue for chunked audio playback -->
99
- <div id="audioQueueContainer" hidden></div>
100
  </div>
101
 
102
- <script type="module">
103
- const generateButton = document.getElementById('generateButton');
 
 
104
  const audioPlayer = document.getElementById('audioPlayer');
105
- const statusDiv = document.getElementById('status');
106
- const transcriptArea = document.getElementById('transcriptArea');
107
- const voiceSelector = document.getElementById('voiceSelector');
 
 
108
  const speedSelector = document.getElementById('speedSelector');
109
- const charCountDiv = document.getElementById('charCount');
110
- const audioQueueContainer = document.getElementById('audioQueueContainer');
111
 
112
- const MAX_CHARS = 10000;
113
- const CHUNK_SIZE = 1800; // Chunk size is still dictated by the API limit
114
- let audioQueue = [];
115
-
116
- // --- Utility Functions for Audio Generation ---
117
-
118
- function base64ToArrayBuffer(base64) {
119
- const binaryString = atob(base64);
120
- const len = binaryString.length;
121
- const bytes = new Uint8Array(len);
122
- for (let i = 0; i < len; i++) {
123
- bytes[i] = binaryString.charCodeAt(i);
 
124
  }
125
- return bytes.buffer;
126
- }
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- function pcmToWav(pcm16, sampleRate = 16000) {
129
- const numChannels = 1;
130
- const bytesPerSample = 2;
131
- const dataLength = pcm16.length * bytesPerSample;
132
- const buffer = new ArrayBuffer(44 + dataLength);
133
- const view = new DataView(buffer);
134
 
135
- function writeString(view, offset, string) {
136
- for (let i = 0; i < string.length; i++) {
137
- view.setUint8(offset + i, string.charCodeAt(i));
 
 
 
 
 
 
 
138
  }
139
- }
140
 
141
- // RIFF chunk
142
- writeString(view, 0, 'RIFF');
143
- view.setUint32(4, 36 + dataLength, true);
144
- writeString(view, 8, 'WAVE');
145
-
146
- // FMT sub-chunk
147
- writeString(view, 12, 'fmt ');
148
- view.setUint32(16, 16, true);
149
- view.setUint16(20, 1, true);
150
- view.setUint16(22, numChannels, true);
151
- view.setUint32(24, sampleRate, true);
152
- view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
153
- view.setUint16(32, numChannels * bytesPerSample, true);
154
- view.setUint16(34, 16, true);
155
-
156
- // Data sub-chunk
157
- writeString(view, 36, 'data');
158
- view.setUint32(40, dataLength, true);
159
-
160
- // Write PCM data
161
- let offset = 44;
162
- for (let i = 0; i < pcm16.length; i++) {
163
- view.setInt16(offset, pcm16[i], true);
164
- offset += 2;
165
  }
 
166
 
167
- return new Blob([buffer], { type: 'audio/wav' });
 
 
 
 
 
 
168
  }
169
-
170
- // ** UPDATED fetchAudio to call the local proxy endpoint **
171
- async function fetchAudio(prompt, voice, retries = 5) {
172
- // The API call is now directed to the local backend proxy
173
- const proxyUrl = "/tts-proxy";
174
-
175
- const payload = {
176
- prompt: prompt,
177
- voice: voice
178
- };
179
-
180
- for (let i = 0; i < retries; i++) {
181
- try {
182
- const response = await fetch(proxyUrl, {
183
- method: 'POST',
184
- headers: { 'Content-Type': 'application/json' },
185
- body: JSON.stringify(payload)
186
- });
187
-
188
- const result = await response.json();
189
-
190
- if (!response.ok) {
191
- // Handle 429/rate limiting from proxy
192
- if (response.status === 429 && i < retries - 1) {
193
- const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
194
- await new Promise(resolve => setTimeout(resolve, delay));
195
- continue;
196
- }
197
- const errorMessage = result.error || `Proxy request failed with status: ${response.status}`;
198
- throw new Error(errorMessage);
199
- }
200
-
201
- // The proxy returns { audioData: base64_string }
202
- const audioData = result.audioData;
203
-
204
- if (audioData) {
205
- // Assuming 16000Hz rate and L16 format from the API
206
- const sampleRate = 16000;
207
- const pcmData = base64ToArrayBuffer(audioData);
208
- const pcm16 = new Int16Array(pcmData);
209
- const wavBlob = pcmToWav(pcm16, sampleRate);
210
- const audioUrl = URL.createObjectURL(wavBlob);
211
-
212
- return audioUrl;
213
- } else {
214
- throw new Error("Missing audio data in proxy response.");
215
- }
216
-
217
- } catch (error) {
218
- console.error("Fetch attempt failed:", error);
219
- if (i === retries - 1) throw error;
220
- }
221
  }
222
  }
223
-
224
- // --- Text Chunking and Audio Concatenation Logic (Unchanged, as it handles the 10k limit) ---
225
-
226
- function getChunks(text) {
227
- const chunks = [];
228
- let sentences = text.match(/[^.!?\n]+[.!?\n]*/g) || [text];
229
- let currentChunk = '';
230
-
231
- for (const sentence of sentences) {
232
- if (currentChunk.length + sentence.length > CHUNK_SIZE) {
233
- if (currentChunk.length > 0) {
234
- chunks.push(currentChunk.trim());
235
- currentChunk = sentence;
236
- } else {
237
- let remaining = sentence;
238
- while (remaining.length > 0) {
239
- chunks.push(remaining.substring(0, CHUNK_SIZE).trim());
240
- remaining = remaining.substring(CHUNK_SIZE);
241
- }
242
- currentChunk = '';
243
- }
244
- } else {
245
- currentChunk += sentence;
246
- }
247
  }
 
248
 
249
- if (currentChunk.length > 0) {
250
- chunks.push(currentChunk.trim());
 
 
 
251
  }
 
 
 
 
 
 
 
252
 
253
- return chunks.filter(c => c.length > 0);
254
- }
255
 
256
- async function generateChunkedAudio(prompt, voice) {
257
- const chunks = getChunks(prompt);
258
- const generatedUrls = [];
259
-
260
- for (let i = 0; i < chunks.length; i++) {
261
- statusDiv.textContent = `Generating audio chunk ${i + 1} of ${chunks.length}...`;
262
- try {
263
- const url = await fetchAudio(chunks[i], voice);
264
- generatedUrls.push(url);
265
- } catch (error) {
266
- statusDiv.textContent = `Error on chunk ${i + 1}. Details in console.`;
267
- throw error;
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  }
270
- return generatedUrls;
271
  }
272
 
273
- function setupPlaybackQueue(urls) {
274
- audioQueue.forEach(a => {
275
- if (a.src) URL.revokeObjectURL(a.src);
276
- a.remove();
277
- });
278
- audioQueue = [];
279
- audioQueueContainer.innerHTML = '';
280
-
281
- audioPlayer.hidden = true;
282
- audioPlayer.removeAttribute('src');
283
-
284
- if (urls.length === 0) return;
285
 
286
- audioPlayer.src = urls[0];
287
- audioPlayer.hidden = false;
288
- audioPlayer.playbackRate = parseFloat(speedSelector.value);
 
 
289
 
290
- if (urls.length > 1) {
291
- let currentAudio = audioPlayer;
292
- for (let i = 1; i < urls.length; i++) {
293
- const nextAudio = document.createElement('audio');
294
- nextAudio.src = urls[i];
295
- nextAudio.playbackRate = parseFloat(speedSelector.value);
296
- audioQueueContainer.appendChild(nextAudio);
297
- audioQueue.push(nextAudio);
298
-
299
- currentAudio.addEventListener('ended', () => {
300
- nextAudio.play();
301
- }, { once: true });
302
 
303
- currentAudio = nextAudio;
 
304
  }
305
  }
 
306
 
307
- const allPlayers = [audioPlayer, ...audioQueue];
308
- speedSelector.onchange = () => {
309
- const newRate = parseFloat(speedSelector.value);
310
- allPlayers.forEach(p => p.playbackRate = newRate);
311
- };
312
- }
313
 
 
 
 
314
 
315
- // --- Event Handlers ---
316
-
317
- function updateCharCount() {
318
- const text = transcriptArea.value;
319
- const count = text.length;
320
- charCountDiv.textContent = `${count} / ${MAX_CHARS} Characters`;
321
-
322
- if (count > MAX_CHARS) {
323
- transcriptArea.value = text.substring(0, MAX_CHARS);
324
- charCountDiv.classList.add('text-red-600');
325
- generateButton.disabled = true;
326
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
327
- statusDiv.textContent = `Text exceeds the ${MAX_CHARS} character limit.`;
328
- } else if (count === 0) {
329
- generateButton.disabled = true;
330
- charCountDiv.classList.remove('text-red-600');
331
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-gray-100 text-gray-700 transition duration-300';
332
- statusDiv.textContent = 'Enter text and click "Generate & Play".';
333
- } else {
334
- generateButton.disabled = false;
335
- charCountDiv.classList.remove('text-red-600');
336
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-indigo-100 text-indigo-800 transition duration-300';
337
- statusDiv.textContent = 'Text ready to generate.';
338
- }
339
-
340
- audioPlayer.hidden = true;
341
- audioPlayer.removeAttribute('src');
342
  }
343
-
344
- speedSelector.addEventListener('change', () => {
345
- if (audioPlayer.src && audioQueue.length === 0) {
346
- audioPlayer.playbackRate = parseFloat(speedSelector.value);
347
- }
348
- });
349
-
350
- window.onload = updateCharCount;
351
- transcriptArea.addEventListener('input', updateCharCount);
352
 
 
 
 
353
 
354
- generateButton.addEventListener('click', async () => {
355
- const prompt = transcriptArea.value.trim();
356
- const voice = voiceSelector.value;
357
- const voiceName = voiceSelector.options[voiceSelector.selectedIndex].text;
 
 
 
358
 
359
- if (prompt.length === 0) {
360
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
361
- statusDiv.textContent = 'Please paste or type text before generating audio.';
362
- return;
363
- }
364
-
365
- setupPlaybackQueue([]);
366
- generateButton.disabled = true;
367
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-yellow-100 text-yellow-800 transition duration-300';
368
- statusDiv.textContent = `Starting generation with ${voiceName} voice...`;
369
 
370
- try {
371
- const audioUrls = await generateChunkedAudio(prompt, voice);
372
-
373
- setupPlaybackQueue(audioUrls);
374
- // We use setTimeout to ensure the HTML element is ready before attempting play
375
- setTimeout(() => audioPlayer.play(), 100);
376
 
377
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-green-100 text-green-800 transition duration-300';
378
- statusDiv.textContent = `Audio generated successfully in ${audioUrls.length} chunks. Playing automatically.`;
379
-
380
- } catch (error) {
381
- statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
382
- statusDiv.textContent = `Error generating audio: ${error.message}`;
383
- console.error("Full TTS generation error:", error);
384
- } finally {
385
- generateButton.disabled = false;
386
  }
387
- });
388
  </script>
 
389
  </body>
390
  </html>
 
 
1
  <html lang="en">
2
  <head>
3
  <meta charset="UTF-8">
4
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <title>Dynamic English Study Studio</title>
6
  <script src="https://cdn.tailwindcss.com"></script>
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
9
  body {
10
  font-family: 'Inter', sans-serif;
11
+ background-color: #f7f9fc;
 
 
 
 
 
12
  }
13
  .container {
14
+ max-width: 1000px;
15
+ margin: 0 auto;
16
+ padding: 20px;
 
 
 
17
  }
18
+ .card {
19
+ background-color: white;
20
+ border-radius: 12px;
21
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
22
+ padding: 24px;
23
  }
24
+ .btn-primary {
25
+ background-color: #4f46e5;
26
+ color: white;
27
+ transition: background-color 0.2s;
28
  }
29
+ .btn-primary:hover {
30
+ background-color: #4338ca;
31
+ }
32
+ .btn-secondary {
33
+ background-color: #e5e7eb;
34
  color: #1f2937;
35
+ transition: background-color 0.2s;
36
+ }
37
+ .btn-secondary:hover {
38
+ background-color: #d1d5db;
39
+ }
40
+ /* Styling for the Modal (Carousel Simulation) */
41
+ .modal {
42
+ display: none;
43
+ position: fixed;
44
+ z-index: 1000;
45
+ left: 0;
46
+ top: 0;
47
+ width: 100%;
48
+ height: 100%;
49
+ overflow: auto;
50
+ background-color: rgba(0,0,0,0.4);
51
+ backdrop-filter: blur(5px);
52
+ }
53
+ .modal-content {
54
+ background-color: #fff;
55
+ margin: 10% auto;
56
+ padding: 20px;
57
+ border-radius: 10px;
58
+ width: 90%;
59
+ max-width: 500px;
60
+ box-shadow: 0 8px 25px rgba(0,0,0,0.2);
61
+ animation: slideIn 0.3s ease-out;
62
  }
63
+ @keyframes slideIn {
64
+ from { opacity: 0; transform: translateY(-30px); }
65
+ to { opacity: 1; transform: translateY(0); }
66
+ }
67
+ .close {
68
+ color: #aaa;
69
+ float: right;
70
+ font-size: 28px;
71
+ font-weight: bold;
72
+ }
73
+ .close:hover,
74
+ .close:focus {
75
+ color: #333;
76
+ text-decoration: none;
77
+ cursor: pointer;
78
+ }
79
+ .tab-button.active {
80
+ background-color: #4f46e5;
81
+ color: white;
82
+ border-bottom: 2px solid #4f46e5;
83
+ }
84
+ /* Loading Style (Spinner) */
85
+ .spinner {
86
+ border: 4px solid rgba(0, 0, 0, 0.1);
87
+ width: 24px;
88
+ height: 24px;
89
+ border-radius: 50%;
90
+ border-left-color: #4f46e5;
91
+ animation: spin 1s ease infinite;
92
+ }
93
+ @keyframes spin {
94
+ 0% { transform: rotate(0deg); }
95
+ 100% { transform: rotate(360deg); }
96
  }
97
  </style>
98
  </head>
99
  <body>
100
+
101
+ <div class="container py-8">
102
+ <header class="text-center mb-10">
103
+ <h1 class="text-3xl font-bold text-gray-800">Dynamic English Study Studio</h1>
104
+ <p class="text-gray-500">Audio and Contextualization tool for language studies.</p>
105
+ </header>
106
+
107
+ <div class="card mb-6">
108
+ <h2 class="text-xl font-semibold mb-4 text-gray-700">Your Text for Study</h2>
109
+ <textarea id="textInput"
110
+ placeholder="Paste the English text you want to listen to and study here. Double-click any word for translation and context!"
111
+ class="w-full p-4 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 h-64 resize-none text-gray-800"
112
+ maxlength="5000"></textarea>
113
+ <p class="text-sm text-right text-gray-500 mt-2">Characters: <span id="charCount">0</span>/5000</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
 
116
+ <div class="card mb-6">
117
+ <h2 class="text-xl font-semibold mb-4 text-gray-700">Audio and Reading Controls</h2>
118
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
119
+
120
+ <!-- Voice Selection (Kept for Future APIs or UX) -->
121
+ <div>
122
+ <label for="voiceSelector" class="block text-sm font-medium text-gray-700">Voice (gTTS)</label>
123
+ <select id="voiceSelector" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm">
124
+ <!-- gTTS uses US accent by default; selector is for UX/Future APIs -->
125
+ <option value="us_man">American Man</option>
126
+ <option value="us_woman">American Woman</option>
127
+ </select>
128
+ </div>
129
+
130
+ <!-- Speed Control -->
131
+ <div>
132
+ <label for="speedSelector" class="block text-sm font-medium text-gray-700">Speaking Rate</label>
133
+ <select id="speedSelector" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm">
134
+ <option value="1.0">Normal (1.0x)</option>
135
+ <option value="0.75">Slow (0.75x)</option>
136
+ <option value="1.25">Fast (1.25x)</option>
137
+ </select>
138
+ </div>
139
+
140
+ <!-- Main Play Button -->
141
+ <div class="md:col-span-1 flex items-end">
142
+ <button id="playButton" onclick="handlePlay()" class="btn-primary w-full py-2.5 rounded-lg font-semibold flex items-center justify-center disabled:opacity-50" disabled>
143
+ <span id="playText">Generate & Play Audio</span>
144
+ <div id="loadingSpinner" class="spinner ml-3 hidden"></div>
145
+ </button>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Player Controls and Phrase Loop -->
150
+ <div class="mt-4 border-t pt-4">
151
+ <audio id="audioPlayer" controls class="w-full mb-4"></audio>
152
+
153
+ <div class="flex flex-wrap gap-3">
154
+ <button id="loopSelectionButton" onclick="setLoopMode()" class="btn-secondary px-4 py-2 rounded-lg font-medium disabled:opacity-50" disabled>
155
+ Loop Selection (Repeat Phrase)
156
+ </button>
157
+ <button id="clearLoopButton" onclick="clearLoopMode()" class="btn-secondary px-4 py-2 rounded-lg font-medium disabled:opacity-50" disabled>
158
+ Clear Loop
159
+ </button>
160
+ </div>
161
+ </div>
162
  </div>
163
+ </div>
164
+
165
+ <!-- Translation and Context Modal -->
166
+ <div id="explanationModal" class="modal">
167
+ <div class="modal-content">
168
+ <span class="close" onclick="closeModal()">&times;</span>
169
+ <h3 class="text-2xl font-bold mb-4 text-gray-800">Word Analysis: <span id="wordSelected" class="text-indigo-600"></span></h3>
170
+
171
+ <!-- Tab Navigation (Carousel Simulation) -->
172
+ <div class="flex border-b mb-4">
173
+ <button class="tab-button active px-4 py-2 text-sm font-medium rounded-t-lg" data-tab="explanation">Explanation (EN)</button>
174
+ <button class="tab-button px-4 py-2 text-sm font-medium rounded-t-lg" data-tab="translation">Translation (PT)</button>
175
+ </div>
176
 
177
+ <!-- Tab Content -->
178
+ <div id="modalContent">
179
+ <div id="explanationTab" class="tab-content">
180
+ <p class="text-gray-600 italic">Context and Definition in English:</p>
181
+ <div id="explanationResult" class="mt-2 p-3 bg-gray-50 rounded-md min-h-[50px]">
182
+ <div id="explanationSpinner" class="spinner mx-auto hidden"></div>
183
+ <p id="explanationText" class="text-gray-800"></p>
184
+ </div>
185
+ </div>
186
+ <div id="translationTab" class="tab-content hidden">
187
+ <p class="text-gray-600 italic">Translation to Portuguese:</p>
188
+ <div id="translationResult" class="mt-2 p-3 bg-gray-50 rounded-md min-h-[50px]">
189
+ <div id="translationSpinner" class="spinner mx-auto hidden"></div>
190
+ <p id="translationText" class="text-gray-800"></p>
191
+ </div>
192
+ </div>
193
+ </div>
194
 
195
+ <button onclick="closeModal()" class="btn-primary mt-6 w-full py-2.5 rounded-lg font-semibold">Close</button>
196
+ </div>
 
 
 
197
  </div>
198
 
199
+ <script>
200
+ // Global variables
201
+ const textInput = document.getElementById('textInput');
202
+ const playButton = document.getElementById('playButton');
203
  const audioPlayer = document.getElementById('audioPlayer');
204
+ const charCount = document.getElementById('charCount');
205
+ const loadingSpinner = document.getElementById('loadingSpinner');
206
+ const playText = document.getElementById('playText');
207
+ const loopSelectionButton = document.getElementById('loopSelectionButton');
208
+ const clearLoopButton = document.getElementById('clearLoopButton');
209
  const speedSelector = document.getElementById('speedSelector');
 
 
210
 
211
+ const MAX_CHARS = 5000;
212
+ let isLooping = false;
213
+ let loopText = '';
214
+ let lastAudioUrl = '';
215
+
216
+ // --- Interface Management ---
217
+
218
+ textInput.addEventListener('input', () => {
219
+ const currentLength = textInput.value.length;
220
+ charCount.textContent = currentLength;
221
+ // Limit input to max characters
222
+ if (currentLength > MAX_CHARS) {
223
+ textInput.value = textInput.value.substring(0, MAX_CHARS);
224
  }
225
+ // Enable button if there is text
226
+ playButton.disabled = currentLength === 0;
227
+ // The logic for enabling loop button on selection will be handled below
228
+ });
229
+
230
+ // Update character count on load
231
+ textInput.dispatchEvent(new Event('input'));
232
+
233
+ speedSelector.addEventListener('change', () => {
234
+ audioPlayer.playbackRate = parseFloat(speedSelector.value);
235
+ });
236
+
237
+ // --- Main Audio and Loop Functionality ---
238
 
239
+ async function fetchAudio(text) {
240
+ playButton.disabled = true;
241
+ playText.textContent = "Generating Audio...";
242
+ loadingSpinner.classList.remove('hidden');
 
 
243
 
244
+ try {
245
+ // Route for the backend using gTTS
246
+ const response = await fetch('/tts-proxy', {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ text: text })
250
+ });
251
+
252
+ if (!response.ok) {
253
+ throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
254
  }
 
255
 
256
+ // The backend returns an audio blob (MP3)
257
+ const audioBlob = await response.blob();
258
+
259
+ // Revoke previous URL to free up memory
260
+ if (lastAudioUrl) URL.revokeObjectURL(lastAudioUrl);
261
+
262
+ lastAudioUrl = URL.createObjectURL(audioBlob);
263
+ audioPlayer.src = lastAudioUrl;
264
+ audioPlayer.playbackRate = parseFloat(speedSelector.value);
265
+ audioPlayer.play();
266
+
267
+ } catch (error) {
268
+ console.error('Error generating audio:', error);
269
+ alert('Error generating audio. Check the console or the status of your backend (app.py).');
270
+ } finally {
271
+ playButton.disabled = false;
272
+ playText.textContent = "Generate & Play Audio";
273
+ loadingSpinner.classList.add('hidden');
 
 
 
 
 
 
274
  }
275
+ }
276
 
277
+ function handlePlay() {
278
+ isLooping = false;
279
+ clearLoopButton.disabled = true;
280
+ const textToPlay = textInput.value.trim();
281
+ if (textToPlay) {
282
+ fetchAudio(textToPlay);
283
+ }
284
  }
285
+
286
+ // Sets the selection for repetition (Phrase Loop)
287
+ function setLoopMode() {
288
+ const selection = window.getSelection().toString().trim();
289
+ if (selection) {
290
+ loopText = selection;
291
+ isLooping = true;
292
+ clearLoopButton.disabled = false;
293
+ fetchAudio(loopText);
294
+ audioPlayer.title = 'Looping: ' + loopText;
295
+ } else {
296
+ alert('Select the text you wish to repeat.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  }
298
  }
299
+
300
+ function clearLoopMode() {
301
+ isLooping = false;
302
+ loopText = '';
303
+ clearLoopButton.disabled = true;
304
+ audioPlayer.loop = false;
305
+ audioPlayer.title = '';
306
+ // Return to main audio (if available)
307
+ if (textInput.value.trim()) {
308
+ handlePlay();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
310
+ }
311
 
312
+ audioPlayer.addEventListener('ended', () => {
313
+ if (isLooping) {
314
+ // If in loop mode, restart playback
315
+ audioPlayer.currentTime = 0;
316
+ audioPlayer.play();
317
  }
318
+ });
319
+
320
+ // Enable Loop button if there is a selection
321
+ textInput.addEventListener('select', () => {
322
+ const selection = window.getSelection().toString().trim();
323
+ loopSelectionButton.disabled = !selection;
324
+ });
325
 
 
 
326
 
327
+ // --- Quick Translation Functionality (Double Click) ---
328
+
329
+ // Activate the AI API call for translation
330
+ async function fetchExplanationAndTranslation(word, context) {
331
+ const explanationSpinner = document.getElementById('explanationSpinner');
332
+ const translationSpinner = document.getElementById('translationSpinner');
333
+ const explanationText = document.getElementById('explanationText');
334
+ const translationText = document.getElementById('translationText');
335
+
336
+ explanationText.textContent = '';
337
+ translationText.textContent = '';
338
+ explanationSpinner.classList.remove('hidden');
339
+ translationSpinner.classList.remove('hidden');
340
+
341
+ try {
342
+ // Route for the backend using Gemini for explanation/translation
343
+ const response = await fetch('/explain-proxy', {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ // Sends the clicked word and context to the AI
347
+ body: JSON.stringify({ word: word, context: context })
348
+ });
349
+
350
+ if (!response.ok) {
351
+ throw new Error(`HTTP Error: ${response.status}. Check if GEMINI_API_KEY is configured in your Space.`);
352
  }
353
+
354
+ const data = await response.json();
355
+
356
+ // Assumes the backend returns an object { explanation: "...", translation: "..." }
357
+ explanationText.textContent = data.explanation || 'Could not retrieve explanation.';
358
+ translationText.textContent = data.translation || 'Could not retrieve translation.';
359
+
360
+ } catch (error) {
361
+ console.error('Error fetching context from AI:', error);
362
+ explanationText.textContent = 'Error connecting to AI. Check if GEMINI_API_KEY is configured correctly.';
363
+ translationText.textContent = 'Error connecting to AI.';
364
+ } finally {
365
+ explanationSpinner.classList.add('hidden');
366
+ translationSpinner.classList.add('hidden');
367
  }
 
368
  }
369
 
370
+ // Logic to open the Modal on double click
371
+ textInput.addEventListener('dblclick', (event) => {
372
+ const target = event.target;
373
+ if (target.id === 'textInput') {
374
+ const selection = window.getSelection();
375
+ // Expand selection to the nearest word
376
+ selection.modify('move', 'backward', 'word');
377
+ selection.modify('extend', 'forward', 'word');
378
+
379
+ const selectedWord = selection.toString().trim().replace(/[^a-zA-Z']/g, '');
 
 
380
 
381
+ if (selectedWord) {
382
+ const fullText = textInput.value;
383
+ const index = fullText.indexOf(selectedWord);
384
+ // Capture context (a few words before and after)
385
+ const context = fullText.substring(Math.max(0, index - 50), index + selectedWord.length + 50);
386
 
387
+ document.getElementById('wordSelected').textContent = selectedWord;
388
+ openModal();
 
 
 
 
 
 
 
 
 
 
389
 
390
+ // Start fetching explanation and translation
391
+ fetchExplanationAndTranslation(selectedWord, context);
392
  }
393
  }
394
+ });
395
 
396
+ // --- Modal Control ---
 
 
 
 
 
397
 
398
+ const modal = document.getElementById('explanationModal');
399
+ const tabButtons = document.querySelectorAll('.tab-button');
400
+ const tabContents = document.querySelectorAll('.tab-content');
401
 
402
+ function openModal() {
403
+ modal.style.display = 'block';
404
+ // Ensure Explanation tab is the first one shown
405
+ showTab('explanation');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  }
 
 
 
 
 
 
 
 
 
407
 
408
+ function closeModal() {
409
+ modal.style.display = 'none';
410
+ }
411
 
412
+ function showTab(tabName) {
413
+ tabContents.forEach(content => {
414
+ content.classList.add('hidden');
415
+ if (content.id === tabName + 'Tab') {
416
+ content.classList.remove('hidden');
417
+ }
418
+ });
419
 
420
+ tabButtons.forEach(button => {
421
+ button.classList.remove('active');
422
+ if (button.dataset.tab === tabName) {
423
+ button.classList.add('active');
424
+ }
425
+ });
426
+ }
 
 
 
427
 
428
+ tabButtons.forEach(button => {
429
+ button.addEventListener('click', () => {
430
+ showTab(button.dataset.tab);
431
+ });
432
+ });
 
433
 
434
+ // Close modal when clicking outside of it
435
+ window.onclick = function(event) {
436
+ if (event.target == modal) {
437
+ closeModal();
 
 
 
 
 
438
  }
439
+ }
440
  </script>
441
+
442
  </body>
443
  </html>