package model import "fmt" func GetTemplate1(streamerID int, donatHost, ttsHost string) string { style := `body { margin: 0; padding: 0; height: 100vh; display: flex; justify-content: center; align-items: flex-start; background-color: #000; font-family: Arial, sans-serif; } #content { display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 1920px; margin: 20px auto; gap: 20px; } #content img { width: 100%; height: auto; max-height: 90vh; object-fit: contain; border-radius: 15px; } .text-container, .donation-user { opacity: 0; animation: fadeIn 2s forwards; } .text-container { display: flex; align-items: center; gap: 20px; font-size: 40px; color: #fff; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); } .donation-user { font-size: 35px; color: #FFD700; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); } .donation-text { margin: 0; } .donation-amount { color: #4CAF50; font-weight: bold; padding: 5px 15px; background: rgba(0,0,0,0.7); border-radius: 8px; } @keyframes fadeIn { to { opacity: 1; } }` script := fmt.Sprintf(` let widgetUrl = 'https://%s/api'; let ttsUrl = 'https://%s/api/tts'; function createTextWithAmount(text, amount) { const container = document.createElement('div'); container.className = 'text-container'; const textElem = document.createElement('p'); textElem.className = 'donation-text'; textElem.textContent = text; const amountElem = document.createElement('div'); amountElem.className = 'donation-amount'; amountElem.textContent = amount + '₽'; container.appendChild(textElem); container.appendChild(amountElem); return container; } function createTextElement(text) { const container = document.createElement('div'); container.className = 'text-container'; const textElem = document.createElement('p'); textElem.className = 'donation-text'; textElem.textContent = text; container.appendChild(textElem); return container; } 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 (getDonatInfo):', error); return null; } } 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; 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, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) { return reject(new DOMException('Aborted', 'AbortError')); } if (!voiceSettings.voice_enabled) { return resolve(null); } const requestBody = { text: text, speed: (voiceSettings.voice_speed || 'medium').toLowerCase(), scenery: voiceSettings.scenery || 'default', sound_percent: voiceSettings.voice_sound_percent || 100, min_price: voiceSettings.min_price || 0, 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), signal: signal // Pass signal to fetch }) .then(response => { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); if (!response.ok) throw new Error('TTS API error: ' + response.status); return response.blob(); }) .then(blob => { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); objectUrl = URL.createObjectURL(blob); audio = new Audio(objectUrl); audio.volume = (voiceSettings.voice_sound_percent || 100) / 100; 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(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 }); }); } async function playMedia(donat, voiceSettings) { let audioElement = null; let ttsAudio = null; const controller = new AbortController(); let timeoutId; 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); } } 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. } })(); 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 { 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.paused) { ttsAudio.pause(); ttsAudio.currentTime = 0; } } } function clearContainer(container) { while (container.firstChild) { container.removeChild(container.firstChild); } } async function widgetView() { const streamerID = '%v'; const contentDiv = document.getElementById('content'); if (!contentDiv) { console.error('Content container not found!'); return; } while (true) { const iterationStart = Date.now(); let currentDonat = null; // Store current donat for duration calculation try { 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 (currentDonat.image_link) { const img = document.createElement('img'); img.src = currentDonat.image_link; img.alt = "Donation Image"; contentDiv.appendChild(img); } if (currentDonat.show_name && currentDonat.donat_user) { const userElem = document.createElement('div'); userElem.className = 'donation-user'; userElem.textContent = currentDonat.donat_user; contentDiv.appendChild(userElem); } 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: 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); if (currentDonat.order_id) { try { await fetch(widgetUrl + '/widget/donat/viewed', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({order_id: currentDonat.order_id}), }); } catch (error) { console.error('Ошибка подтверждения просмотра:', error); } } } catch (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 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 } } document.addEventListener('DOMContentLoaded', widgetView);`, donatHost, ttsHost, streamerID) template := fmt.Sprintf(`
`, style, script) return template }