1/** 2 * "SluÅ¡aj Älanak" â Äitanje naslova i sadržaja preko Web Speech API-ja. 3 * Native-style kontrole: play, vrijeme, progress, volume, izbornik brzine. 4 */ 5(function () { 6 'use strict'; 7 8 if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') { 9 return; 10 } 11 12 var player = document.getElementById('ttsPlayer'); 13 var content = document.getElementById('ttsContent'); 14 if (!player || !content) { 15 return; 16 } 17 18 var synth = window.speechSynthesis; 19 var isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || 20 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 21 22 var toggleBtn = player.querySelector('.tts-player__toggle'); 23 var currentEl = player.querySelector('.tts-player__current'); 24 var durationEl = player.querySelector('.tts-player__duration'); 25 var barEl = player.querySelector('.tts-player__bar'); 26 var progressEl = player.querySelector('.tts-player__progress'); 27 var muteBtn = player.querySelector('.tts-player__mute'); 28 var volumeRange = player.querySelector('.tts-player__volume-range'); 29 var menuBtn = player.querySelector('.tts-player__menu-btn'); 30 var menuPanel = player.querySelector('.tts-player__menu-panel'); 31 var rateInputs = player.querySelectorAll('.tts-player__rate-input'); 32 33 var chunks = buildChunks(); 34 if (!chunks.length) { 35 return; 36 } 37 38 var chunkLengths = chunks.map(function (c) { return c.length; }); 39 var totalChars = chunkLengths.reduce(function (a, b) { return a + b; }, 0); 40 41 var state = 'idle'; 42 var currentIndex = 0; 43 var charProgress = 0; 44 var runId = 0; 45 var voice = null; 46 var volumeLevel = 1; 47 var isMuted = false; 48 var savedVolume = 1; 49 50 player.hidden = false; 51 52 function buildChunks() { 53 var parts = []; 54 var title = (player.getAttribute('data-tts-title') || '').trim(); 55 if (title) { 56 parts.push(title); 57 } 58 var body = (content.innerText || content.textContent || '').trim(); 59 if (body) { 60 parts.push(body); 61 } 62 var text = parts.join('. '); 63 64 var sentences = text 65 .replace(/\s+/g, ' ') 66 .match(/[^.!?â¦]+[.!?â¦]*\s*/g) || [text]; 67 68 var result = []; 69 var buffer = ''; 70 var MAX = 320; 71 sentences.forEach(function (s) { 72 s = s.trim(); 73 if (!s) return; 74 if ((buffer + ' ' + s).trim().length > MAX && buffer) { 75 result.push(buffer.trim()); 76 buffer = s; 77 } else { 78 buffer = (buffer + ' ' + s).trim(); 79 } 80 }); 81 if (buffer.trim()) { 82 result.push(buffer.trim()); 83 } 84 return result; 85 } 86 87 function pickVoice() { 88 var voices = synth.getVoices() || []; 89 if (!voices.length) { 90 return null; 91 } 92 var hr = voices.filter(function (v) { 93 return /^hr(\b|-)/i.test(v.lang || ''); 94 }); 95 return hr[0] || null; 96 } 97 98 if (typeof synth.onvoiceschanged !== 'undefined') { 99 synth.onvoiceschanged = function () { 100 voice = pickVoice(); 101 }; 102 } 103 104 function getRate() { 105 var checked = player.querySelector('.tts-player__rate-input:checked'); 106 return checked ? parseFloat(checked.value) : 1; 107 } 108 109 function countWords() { 110 return chunks.join(' ').split(/\s+/).filter(Boolean).length; 111 } 112 113 function estimateDuration() { 114 var rate = getRate(); 115 var words = countWords(); 116 return Math.max(1, Math.ceil(words / (165 * rate) * 60)); 117 } 118 119 function formatTime(seconds) { 120 seconds = Math.max(0, Math.floor(seconds)); 121 var m = Math.floor(seconds / 60); 122 var s = seconds % 60; 123 return m + ':' + (s < 10 ? '0' : '') + s; 124 } 125 126 function getProgressRatio() { 127 if (!totalChars) { 128 return 0; 129 } 130 var done = 0; 131 for (var i = 0; i < currentIndex; i++) { 132 done += chunkLengths[i]; 133 } 134 done += charProgress; 135 return Math.min(1, done / totalChars); 136 } 137 138 function makeUtterance(text) { 139 var u = new SpeechSynthesisUtterance(text); 140 u.lang = 'hr-HR'; 141 u.rate = getRate(); 142 u.volume = isMuted ? 0 : volumeLevel; 143 if (!voice) { 144 voice = pickVoice(); 145 } 146 if (voice) { 147 u.voice = voice; 148 } 149 return u; 150 } 151 152 function enqueueFrom(index) { 153 synth.cancel();
154 runId++; 155 var myRun = runId; 156 currentIndex = index; 157 charProgress = 0; 158 159 if (currentIndex >= chunks.length) { 160 finish(); 161 return; 162 } 163 164 state = 'speaking'; 165 166 for (var i = index; i < chunks.length; i++) { 167 var u = makeUtterance(chunks[i]); 168 (function (idx, isLast) { 169 u.onstart = function () { 170 if (myRun !== runId) return; 171 currentIndex = idx; 172 charProgress = 0; 173 updateProgress(); 174 }; 175 u.onboundary = function (e) { 176 if (myRun !== runId) return; 177 if (e && typeof e.charIndex === 'number') { 178 charProgress = e.charIndex; 179 updateProgress(); 180 } 181 }; 182 u.onend = function () { 183 if (myRun !== runId) return; 184 if (isLast) { 185 finish(); 186 } 187 }; 188 u.onerror = function () { 189 // GreÅ¡ka (npr. interrupted) â ne ruÅ¡imo player 190 }; 191 })(i, i === chunks.length - 1); 192 synth.speak(u); 193 } 194 195 if (!isIOS && synth.paused) { 196 synth.resume(); 197 } 198 199 updateUI(); 200 updateProgress(); 201 } 202 203 function play() { 204 if (state === 'paused' && !isIOS) { 205 synth.resume(); 206 state = 'speaking'; 207 updateUI(); 208 return; 209 } 210 enqueueFrom(currentIndex); 211 } 212 213 function pause() { 214 if (isIOS) { 215 state = 'paused'; 216 runId++; 217 synth.cancel(); 218 } else { 219 synth.pause(); 220 state = 'paused'; 221 } 222 updateUI(); 223 } 224 225 function finish() { 226 state = 'idle'; 227 currentIndex = 0; 228 charProgress = 0; 229 updateProgress(); 230 updateUI(); 231 } 232 233 function seekToRatio(ratio) { 234 ratio = Math.max(0, Math.min(1, ratio)); 235 if (ratio >= 1) { 236 finish(); 237 return; 238 } 239 var target = Math.floor(ratio * totalChars); 240 var acc = 0; 241 var idx = 0; 242 for (var i = 0; i < chunks.length; i++) { 243 if (acc + chunkLengths[i] > target) { 244 idx = i; 245 break; 246 } 247 acc += chunkLengths[i]; 248 idx = i + 1; 249 } 250 if (idx >= chunks.length) { 251 finish(); 252 return; 253 } 254 charProgress = 0; 255 if (state === 'speaking' || state === 'paused') { 256 enqueueFrom(idx); 257 } else { 258 currentIndex = idx; 259 updateProgress(); 260 } 261 } 262 263 function updateUI() { 264 var speaking = state === 'speaking'; 265 var active = state === 'speaking' || state === 'paused'; 266 267 toggleBtn.setAttribute('aria-label', speaking ? 'Pauziraj Äitanje Älanka' : 'Pokreni Äitanje Älanka'); 268 player.classList.toggle('tts-player--speaking', speaking); 269 player.classList.toggle('tts-player--active', active); 270 } 271 272 function updateProgress() { 273 var ratio = getProgressRatio(); 274 var pct = Math.round(ratio * 100); 275 var duration = estimateDuration(); 276 var current = ratio * duration; 277 278 if (progressEl) progressEl.style.width = pct + '%'; 279 if (barEl) { 280 barEl.setAttribute('aria-valuenow', String(pct)); 281 barEl.setAttribute('aria-valuetext', formatTime(current)); 282 } 283 if (currentEl) currentEl.textContent = formatTime(current); 284 if (durationEl) durationEl.textContent = formatTime(duration); 285 } 286 287 function setMuted(muted) { 288 isMuted = muted; 289 muteBtn.setAttribute('aria-label', muted ? 'UkljuÄi zvuk' : 'GlasnoÄa'); 290 player.classList.toggle('tts-player--muted', muted); 291 } 292 293 function closeMenu() { 294 if (!menuPanel || !menuBtn) return; 295 menuPanel.hidden = true; 296 menuBtn.setAttribute('aria-expanded', 'false'); 297 } 298 299 function openMenu() { 300 if (!menuPanel || !menuBtn) return; 301 menuPanel.hidden = false; 302 menuBtn.setAttribute('aria-expanded', 'true'); 303 } 304 305 toggleBtn.addEventListener('click', function () { 306 if (state === 'speaking') { 307 pause(); 308 } else { 309 play(); 310 } 311 });
312 313 if (barEl) { 314 barEl.addEventListener('click', function (e) { 315 var rect = barEl.getBoundingClientRect(); 316 if (!rect.width) return; 317 var ratio = (e.clientX - rect.left) / rect.width; 318 seekToRatio(ratio); 319 if (state === 'idle' || state === 'paused') { 320 play(); 321 } 322 }); 323 } 324 325 if (muteBtn && volumeRange) { 326 muteBtn.addEventListener('click', function () { 327 if (isMuted) { 328 volumeLevel = savedVolume || 1; 329 volumeRange.value = String(Math.round(volumeLevel * 100)); 330 setMuted(false); 331 } else { 332 savedVolume = volumeLevel; 333 setMuted(true); 334 } 335 if (state === 'speaking') { 336 enqueueFrom(currentIndex); 337 } 338 }); 339 340 volumeRange.addEventListener('input', function () { 341 volumeLevel = parseInt(volumeRange.value, 10) / 100; 342 if (volumeLevel > 0) { 343 savedVolume = volumeLevel; 344 setMuted(false); 345 } else { 346 setMuted(true); 347 } 348 if (state === 'speaking') { 349 enqueueFrom(currentIndex); 350 } 351 }); 352 } 353 354 if (menuBtn && menuPanel) { 355 menuBtn.addEventListener('click', function (e) { 356 e.stopPropagation(); 357 if (menuPanel.hidden) { 358 openMenu(); 359 } else { 360 closeMenu(); 361 } 362 }); 363 364 document.addEventListener('click', function (e) { 365 if (!menuPanel.hidden && !player.contains(e.target)) { 366 closeMenu(); 367 } 368 }); 369 } 370 371 rateInputs.forEach(function (input) { 372 input.addEventListener('change', function () { 373 updateProgress(); 374 if (state === 'speaking' || state === 'paused') { 375 enqueueFrom(currentIndex); 376 } 377 closeMenu(); 378 }); 379 }); 380 381 function cancelOnLeave() { 382 runId++; 383 synth.cancel(); 384 } 385 386 window.addEventListener('pagehide', cancelOnLeave); 387 window.addEventListener('beforeunload', cancelOnLeave); 388 document.addEventListener('visibilitychange', function () { 389 if (document.hidden && state === 'speaking') { 390 pause(); 391 } 392 }); 393 394 voice = pickVoice(); 395 updateUI(); 396 updateProgress(); 397})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.