From a4cbb43ccd6b7babf2cf2da9eb7ac19900e0d9b6 Mon Sep 17 00:00:00 2001 From: harold Date: Wed, 14 May 2025 22:32:46 +0500 Subject: [PATCH] add fix --- internal/model/widget-templates.go | 274 +++++++++++++++++++++-------- 1 file changed, 198 insertions(+), 76 deletions(-) diff --git a/internal/model/widget-templates.go b/internal/model/widget-templates.go index 3ec464e..eb35948 100644 --- a/internal/model/widget-templates.go +++ b/internal/model/widget-templates.go @@ -94,28 +94,83 @@ function createTextElement(text) { async function getDonatInfo(streamerID) { try { let response = await fetch(widgetUrl + '/widget/get-donat-for-playing/' + streamerID); + if (!response.ok) { + console.error('Failed to get donat info:', response.status); + return null; + } return await response.json(); } catch (error) { - console.error('Fetch error:', error); + console.error('Fetch error (getDonatInfo):', error); return null; } } -function playAudio(url, volume) { +function playAudio(url, volume, signal) { return new Promise((resolve, reject) => { + if (signal?.aborted) { + return reject(new DOMException('Aborted', 'AbortError')); + } + const audio = new Audio(url); audio.volume = volume; - audio.play().then(() => { - audio.addEventListener('ended', () => resolve(audio)); - }).catch(error => { - reject(error); + + let resolved = false; + + const onAbort = () => { + if (resolved) return; + resolved = true; + audio.pause(); + audio.src = ""; // Release resources + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('error', onError); + reject(new DOMException('Aborted', 'AbortError')); + }; + + const onEnded = () => { + if (resolved) return; + resolved = true; + signal?.removeEventListener('abort', onAbort); + audio.removeEventListener('error', onError); + resolve(audio); + }; + + const onError = (event) => { + if (resolved) return; + resolved = true; + signal?.removeEventListener('abort', onAbort); + audio.removeEventListener('ended', onEnded); + console.error('Audio playback error:', event); + reject(event.error || new Error('Audio playback failed')); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + audio.addEventListener('ended', onEnded, { once: true }); + audio.addEventListener('error', onError, { once: true }); + + audio.play().catch(err => { + // play() can reject if interrupted or no user gesture + if (resolved) return; + resolved = true; + signal?.removeEventListener('abort', onAbort); + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('error', onError); + if (err.name === 'AbortError' && signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); // Propagate abort + } else { + reject(err); // Other play() error + } }); }); } -function playSpeech(text, voiceSettings) { +function playSpeech(text, voiceSettings, signal) { return new Promise((resolve, reject) => { - if (!voiceSettings.voice_enabled) return resolve(null); + if (signal?.aborted) { + return reject(new DOMException('Aborted', 'AbortError')); + } + if (!voiceSettings.voice_enabled) { + return resolve(null); + } const requestBody = { text: text, @@ -126,27 +181,71 @@ function playSpeech(text, voiceSettings) { languages: voiceSettings.languages || ['ru'] }; + let audio; + let objectUrl; + let resolved = false; + + const cleanupAndReject = (error) => { + if (resolved) return; + resolved = true; + if (audio) { + audio.pause(); + audio.src = ""; + } + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + reject(error); + }; + + const onAbort = () => { + cleanupAndReject(new DOMException('Aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + fetch(ttsUrl + '/generate', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), + signal: signal // Pass signal to fetch }) .then(response => { - if (!response.ok) throw new Error('TTS error'); + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + if (!response.ok) throw new Error('TTS API error: ' + response.status); return response.blob(); }) .then(blob => { - const url = URL.createObjectURL(blob); - const audio = new Audio(url); + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + objectUrl = URL.createObjectURL(blob); + audio = new Audio(objectUrl); audio.volume = (voiceSettings.voice_sound_percent || 100) / 100; - audio.play().then(() => { - audio.addEventListener('ended', () => { - URL.revokeObjectURL(url); - resolve(audio); - }); - }).catch(reject); + + const onEnded = () => { + if (resolved) return; + resolved = true; + signal?.removeEventListener('abort', onAbort); + if (objectUrl) URL.revokeObjectURL(objectUrl); + objectUrl = null; + resolve(audio); + }; + const onError = (event) => { + if (resolved) return; + signal?.removeEventListener('abort', onAbort); + cleanupAndReject(event.error || new Error('TTS audio playback failed')); + }; + + audio.addEventListener('ended', onEnded, { once: true }); + audio.addEventListener('error', onError, { once: true }); + + return audio.play(); }) - .catch(reject); + .catch(error => { + // This catches errors from fetch, blob processing, or audio.play() + if (resolved) return; // Already handled + signal?.removeEventListener('abort', onAbort); // Ensure listener removed + cleanupAndReject(error); // Pass the original error + }); }); } @@ -154,41 +253,51 @@ async function playMedia(donat, voiceSettings) { let audioElement = null; let ttsAudio = null; const controller = new AbortController(); - - try { - const timeoutPromise = new Promise(resolve => - setTimeout(() => { - controller.abort(); - resolve('timeout'); - }, donat.duration * 1000) - ); + let timeoutId; - const mediaPromise = (async () => { - try { - if (donat.play_content && donat.audio_link) { - audioElement = await playAudio( - donat.audio_link, - (voiceSettings.voice_sound_percent || 100) / 100 - ); - if (donat.text && !controller.signal.aborted) { - ttsAudio = await playSpeech(donat.text, voiceSettings); - } - } else if (donat.text && donat.voice_enabled) { - ttsAudio = await playSpeech(donat.text, voiceSettings); + const mediaOperation = (async () => { + try { + if (donat.play_content && donat.audio_link) { + if (controller.signal.aborted) return; + audioElement = await playAudio( + donat.audio_link, + (voiceSettings.voice_sound_percent || 100) / 100, + controller.signal + ); + if (controller.signal.aborted) return; + if (donat.text) { + ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal); } - } catch (e) { - if (!controller.signal.aborted) throw e; + } else if (donat.text && donat.voice_enabled) { + if (controller.signal.aborted) return; + ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal); } - })(); + } catch (error) { + if (error.name !== 'AbortError') { // Only re-throw non-abort errors + console.error('Error during media playback:', error); + throw error; + } + // If AbortError, it's handled by the timeout logic or external abort. + } + })(); - await Promise.race([mediaPromise, timeoutPromise]); + const timeoutPromise = new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); // Signal all operations to stop + resolve('timeout'); // Resolve to indicate timeout completion + }, (donat.duration || 0) * 1000); // Use donat.duration, default to 0 if undefined + }); + try { + await Promise.race([mediaOperation, timeoutPromise]); } finally { - if (audioElement && !audioElement.ended) { + clearTimeout(timeoutId); // Crucial: clear timeout if mediaOperation finished first or errored + // Ensure audio elements are stopped and reset if they were initiated and not properly cleaned up by abort + if (audioElement && !audioElement.paused) { audioElement.pause(); audioElement.currentTime = 0; } - if (ttsAudio && !ttsAudio.ended) { + if (ttsAudio && !ttsAudio.paused) { ttsAudio.pause(); ttsAudio.currentTime = 0; } @@ -212,71 +321,84 @@ async function widgetView() { while (true) { const iterationStart = Date.now(); - + let currentDonat = null; // Store current donat for duration calculation + try { - const donat = await getDonatInfo(streamerID); - if (!donat || Object.keys(donat).length === 0) { - await new Promise(r => setTimeout(r, 5000)); + const donatData = await getDonatInfo(streamerID); + if (!donatData || Object.keys(donatData).length === 0) { + await new Promise(r => setTimeout(r, 5000)); // Wait before retrying continue; } + currentDonat = donatData; // Assign to currentDonat clearContainer(contentDiv); - if (donat.image_link) { + if (currentDonat.image_link) { const img = document.createElement('img'); - img.src = donat.image_link; + img.src = currentDonat.image_link; + img.alt = "Donation Image"; contentDiv.appendChild(img); } - if (donat.show_name && donat.donat_user) { + if (currentDonat.show_name && currentDonat.donat_user) { const userElem = document.createElement('div'); userElem.className = 'donation-user'; - userElem.textContent = donat.donat_user; + userElem.textContent = currentDonat.donat_user; contentDiv.appendChild(userElem); } - if (donat.show_text && donat.text) { - const textElem = donat.amount ? - createTextWithAmount(donat.text, donat.amount) : - createTextElement(donat.text); + if (currentDonat.show_text && currentDonat.text) { + const textElem = currentDonat.amount ? + createTextWithAmount(currentDonat.text, currentDonat.amount) : + createTextElement(currentDonat.text); contentDiv.appendChild(textElem); } const voiceSettings = { - voice_speed: donat.voice_speed, - scenery: donat.scenery, - voice_sound_percent: donat.voice_sound_percent, - min_price: donat.min_price, - languages: donat.languages, - voice_enabled: donat.voice_enabled + voice_speed: currentDonat.voice_speed, + scenery: currentDonat.scenery, + voice_sound_percent: currentDonat.voice_sound_percent, + min_price: currentDonat.min_price, + languages: currentDonat.languages, + voice_enabled: currentDonat.voice_enabled }; + + // playMedia will ensure audio plays for at most currentDonat.duration + await playMedia(currentDonat, voiceSettings); - await playMedia(donat, voiceSettings); - - if (donat.order_id) { + if (currentDonat.order_id) { try { await fetch(widgetUrl + '/widget/donat/viewed', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({order_id: donat.order_id}), + body: JSON.stringify({order_id: currentDonat.order_id}), }); } catch (error) { - console.error('Ошибка подтверждения:', error); + console.error('Ошибка подтверждения просмотра:', error); } } } catch (error) { - console.error('Ошибка обработки доната:', error); + // Log errors from getDonatInfo, playMedia, or other processing + console.error('Ошибка обработки доната в цикле:', error); + // If error, maybe wait a bit before next iteration to avoid spamming + if (!currentDonat) { // e.g. error in getDonatInfo + await new Promise(r => setTimeout(r, 5000)); + continue; + } } - const elapsed = Date.now() - iterationStart; - const remaining = donat?.duration ? - Math.max(donat.duration * 1000 - elapsed, 0) : - 5000 - elapsed; - - if (remaining > 0) { - await new Promise(r => setTimeout(r, remaining)); + const elapsedMs = Date.now() - iterationStart; + // Total display time for the donation (visuals + audio) + // Use currentDonat.duration if available, otherwise default (e.g., 5 seconds) + const targetDisplayTimeMs = (currentDonat?.duration || 5) * 1000; + + const remainingTimeMs = Math.max(0, targetDisplayTimeMs - elapsedMs); + + if (remainingTimeMs > 0) { + await new Promise(r => setTimeout(r, remainingTimeMs)); } + // If elapsedMs already exceeds targetDisplayTimeMs, loop will continue immediately } }