PageSourceSearch

https://grad-vinkovci.hr/js/article-tts.js?v=1789680251

js grad-vinkovci.hr collected 2026-10-02 20:47:32 UTC 11,902 bytes, 397 lines download raw bytes

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.