597 lines
20 KiB
JavaScript
597 lines
20 KiB
JavaScript
// This is free software for the public good of a permacomputer hosted at
|
|
// permacomputer.com, an always-on computer by the people, for the people.
|
|
// One which is durable, easy to repair, & distributed like tap water
|
|
// for machine learning intelligence.
|
|
//
|
|
// The permacomputer is community-owned infrastructure optimized around
|
|
// four values:
|
|
//
|
|
// TRUTH First principles, math & science, open source code freely distributed
|
|
// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control
|
|
// HARMONY Minimal waste, self-renewing systems with diverse thriving connections
|
|
// LOVE Be yourself without hurting others, cooperation through natural law
|
|
//
|
|
// This software contributes to that vision by making machine learning
|
|
// accessible to everyone through a free, open, embeddable chat interface.
|
|
// Code is seeds to sprout on any abandoned technology.
|
|
|
|
import { initializeChunkFiveFont } from "./ui-themes.js";
|
|
import { getUIText } from "./ui-translations.js";
|
|
import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode, primeAudioPlayback, normalizeModelVoice } from "./tts.js";
|
|
import { VOICES_API_URL } from "./config.js";
|
|
|
|
// Function to fetch and populate voice dropdown
|
|
async function populateVoiceDropdown(voiceSelection) {
|
|
// Check cache first (1 minute expiration)
|
|
const cacheKey = 'uncloseai_voices_cache';
|
|
const cacheTimeKey = 'uncloseai_voices_cache_time';
|
|
const cacheVersionKey = 'uncloseai_voices_cache_version';
|
|
const cacheExpiration = 60000; // 1 minute in milliseconds
|
|
const currentVersion = '4'; // Increment this when voice models change (v4: 42+ built-in cloned voices)
|
|
|
|
try {
|
|
const cachedTime = localStorage.getItem(cacheTimeKey);
|
|
const cachedVoices = localStorage.getItem(cacheKey);
|
|
const cachedVersion = localStorage.getItem(cacheVersionKey);
|
|
|
|
// Check if cache is valid (not expired AND correct version)
|
|
if (cachedTime && cachedVoices && cachedVersion === currentVersion) {
|
|
const age = Date.now() - parseInt(cachedTime, 10);
|
|
if (age < cacheExpiration) {
|
|
console.log('Using cached voices data (v' + currentVersion + ')');
|
|
const voices = JSON.parse(cachedVoices);
|
|
renderVoiceOptions(voiceSelection, voices);
|
|
return voices;
|
|
}
|
|
} else if (cachedVersion !== currentVersion) {
|
|
console.log('Cache version mismatch - invalidating old cache');
|
|
// Clear old cache
|
|
localStorage.removeItem(cacheKey);
|
|
localStorage.removeItem(cacheTimeKey);
|
|
localStorage.removeItem(cacheVersionKey);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Cache read failed:', error);
|
|
}
|
|
|
|
// Fetch fresh data
|
|
try {
|
|
console.log('Fetching voices from API...');
|
|
const response = await fetch(VOICES_API_URL);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Transform API response format to frontend format
|
|
// API returns: {data: [{id: "tts-1-qwen", voices: ["alloy", ...]}]}
|
|
// Frontend expects: [{model: "tts-1-qwen", voice: "alloy"}, ...]
|
|
let voices = [];
|
|
if (data.data && Array.isArray(data.data)) {
|
|
data.data.forEach(modelInfo => {
|
|
if (modelInfo.voices && Array.isArray(modelInfo.voices)) {
|
|
modelInfo.voices.forEach(voice => {
|
|
voices.push({ model: modelInfo.id, voice: voice });
|
|
});
|
|
}
|
|
});
|
|
} else if (data.voices && Array.isArray(data.voices)) {
|
|
// Legacy format support
|
|
voices = data.voices;
|
|
}
|
|
|
|
console.log(`Loaded ${voices.length} voices from API`);
|
|
|
|
// Cache the response with version
|
|
try {
|
|
localStorage.setItem(cacheKey, JSON.stringify(voices));
|
|
localStorage.setItem(cacheTimeKey, Date.now().toString());
|
|
localStorage.setItem(cacheVersionKey, currentVersion);
|
|
} catch (error) {
|
|
console.warn('Cache write failed:', error);
|
|
}
|
|
|
|
renderVoiceOptions(voiceSelection, voices);
|
|
return voices;
|
|
} catch (error) {
|
|
console.error('Failed to fetch voices:', error);
|
|
// Fallback to F5-TTS cloned-voice catalog (40 voices, matches sidecar)
|
|
const F5_VOICES = [
|
|
'aria', 'clara', 'elena', 'grace', 'hazel', 'iris', 'luna', 'maya',
|
|
'ruby', 'sage', 'sofia', 'amber', 'brooke', 'cora', 'diana', 'eden',
|
|
'faye', 'gemma', 'hope', 'ivy', 'atlas', 'caleb', 'felix', 'hugo',
|
|
'jasper', 'kai', 'leo', 'marcus', 'owen', 'theo', 'archer', 'blake',
|
|
'cole', 'dane', 'ezra', 'finn', 'grant', 'heath', 'ivan', 'jude'
|
|
];
|
|
const defaultVoices = F5_VOICES.map(voice => ({ model: 'tts-1-f5', voice }));
|
|
renderVoiceOptions(voiceSelection, defaultVoices);
|
|
return defaultVoices;
|
|
}
|
|
}
|
|
|
|
// Function to render voice options in the dropdown
|
|
function renderVoiceOptions(voiceSelection, voices) {
|
|
voiceSelection.innerHTML = ''; // Clear existing options
|
|
|
|
// Get saved voice preference (vault first, fallback to default)
|
|
let savedVoice = 'tts-1-f5:atlas'; // Default
|
|
try {
|
|
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
|
|
savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1-f5:atlas');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to read voice preference:', error);
|
|
}
|
|
|
|
// Group voices by model
|
|
const groupedVoices = {};
|
|
voices.forEach(v => {
|
|
if (!groupedVoices[v.model]) {
|
|
groupedVoices[v.model] = [];
|
|
}
|
|
groupedVoices[v.model].push(v.voice);
|
|
});
|
|
|
|
// Create radio buttons grouped by model
|
|
Object.keys(groupedVoices).sort().forEach(model => {
|
|
// Add model label
|
|
const modelLabel = document.createElement('div');
|
|
modelLabel.className = 'tts-model-label';
|
|
modelLabel.textContent = model;
|
|
voiceSelection.appendChild(modelLabel);
|
|
|
|
// Add voices for this model
|
|
groupedVoices[model].forEach(voice => {
|
|
const label = document.createElement('label');
|
|
label.className = 'tts-voice-label';
|
|
const radio = document.createElement('input');
|
|
radio.type = 'radio';
|
|
radio.name = 'tts-voice';
|
|
radio.value = `${model}:${voice}`;
|
|
|
|
// Check if this is the saved voice
|
|
if (`${model}:${voice}` === savedVoice) {
|
|
radio.checked = true;
|
|
}
|
|
|
|
// Save selection on change (through vault)
|
|
radio.addEventListener('change', () => {
|
|
try {
|
|
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
|
|
window.UncloseVault.set('uncloseai_selected_voice', radio.value);
|
|
} else {
|
|
console.warn('Vault locked - voice preference not persisted');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to save voice preference:', error);
|
|
}
|
|
});
|
|
|
|
label.appendChild(radio);
|
|
label.appendChild(document.createTextNode(voice));
|
|
voiceSelection.appendChild(label);
|
|
});
|
|
});
|
|
|
|
// If no voice is checked, check the first one
|
|
if (!voiceSelection.querySelector('input[type="radio"]:checked')) {
|
|
const firstRadio = voiceSelection.querySelector('input[type="radio"]');
|
|
if (firstRadio) {
|
|
firstRadio.checked = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function openTTSModal() {
|
|
// Load appropriate CSS based on whether we're using PicoCSS or built-in styles
|
|
// Detect if PicoCSS is actually present on the page
|
|
const hasPicoCSS = document.querySelector('link[href*="pico"]') !== null;
|
|
|
|
// Load appropriate CSS based on actual PicoCSS presence
|
|
const cssFile = hasPicoCSS ? 'uncloseai-modal-pico.css' : 'uncloseai-modal-builtin.css';
|
|
|
|
if (!document.querySelector(`link[href*="${cssFile}"]`)) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = `https://uncloseai.com/src/${cssFile}`;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
// Ensure ChunkFive font is loaded
|
|
initializeChunkFiveFont();
|
|
|
|
// Check if Hermes modal is open to set appropriate z-index
|
|
const hermesModal = document.getElementById("uncloseai-embedded-modal");
|
|
const zIndex = hermesModal ? "2001" : "1001";
|
|
|
|
const modal = document.createElement("dialog");
|
|
modal.id = "tts-modal";
|
|
modal.style.zIndex = zIndex;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
const article = document.createElement("article");
|
|
article.className = "tts-modal-article";
|
|
|
|
const header = document.createElement("header");
|
|
header.className = "tts-modal-header";
|
|
|
|
const h1 = document.createElement("h1");
|
|
h1.innerHTML = `<span style="font-family: 'ChunkFiveRegular', monospace;">uncloseai.</span> ${getUIText("ttsModalTitle")}`;
|
|
header.appendChild(h1);
|
|
|
|
const closeButton = document.createElement("button");
|
|
closeButton.textContent = "X";
|
|
closeButton.className = "close-btn";
|
|
closeButton.onclick = () => {
|
|
modal.close();
|
|
document.body.removeChild(modal);
|
|
};
|
|
header.appendChild(closeButton);
|
|
|
|
const textArea = document.createElement("textarea");
|
|
textArea.className = "tts-modal-textarea";
|
|
textArea.placeholder = getUIText("ttsPlaceholder");
|
|
|
|
// Voice selection - dynamically populated from API
|
|
const voiceSelection = document.createElement("div");
|
|
voiceSelection.className = "tts-voice-container";
|
|
|
|
// Populate voice options dynamically
|
|
populateVoiceDropdown(voiceSelection);
|
|
|
|
// Speed control
|
|
const speedControl = document.createElement("div");
|
|
speedControl.className = "tts-speed-control";
|
|
|
|
const speedLabel = document.createElement("label");
|
|
const speedValue = document.createElement("span");
|
|
speedValue.textContent = "0.9";
|
|
|
|
speedLabel.textContent = `${getUIText("speedLabel")} ${speedValue.textContent}`;
|
|
speedLabel.setAttribute("data-i18n", "speedLabel");
|
|
const speedSlider = document.createElement("input");
|
|
speedSlider.type = "range";
|
|
speedSlider.min = "0.25";
|
|
speedSlider.max = "2.0";
|
|
speedSlider.step = "0.05";
|
|
speedSlider.value = "0.9";
|
|
speedSlider.oninput = () => {
|
|
speedValue.textContent = speedSlider.value;
|
|
speedLabel.textContent = `${getUIText("speedLabel")} ${speedSlider.value}`;
|
|
};
|
|
|
|
speedControl.appendChild(speedLabel);
|
|
speedControl.appendChild(speedSlider);
|
|
|
|
// Streaming mode toggle
|
|
const modeControl = document.createElement("div");
|
|
modeControl.className = "tts-mode-control";
|
|
modeControl.style.cssText = "display:flex;align-items:center;gap:8px;margin:8px 0;font-size:0.9em;";
|
|
|
|
const modeLabel = document.createElement("span");
|
|
modeLabel.textContent = "Playback:";
|
|
modeLabel.style.fontWeight = "bold";
|
|
|
|
const modeBuffered = document.createElement("label");
|
|
modeBuffered.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;";
|
|
const radioBuffered = document.createElement("input");
|
|
radioBuffered.type = "radio";
|
|
radioBuffered.name = "tts-mode";
|
|
radioBuffered.value = "buffered";
|
|
radioBuffered.checked = getTTSMode() === "buffered";
|
|
modeBuffered.appendChild(radioBuffered);
|
|
modeBuffered.appendChild(document.createTextNode("Buffered"));
|
|
|
|
const modeStreaming = document.createElement("label");
|
|
modeStreaming.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;";
|
|
const radioStreaming = document.createElement("input");
|
|
radioStreaming.type = "radio";
|
|
radioStreaming.name = "tts-mode";
|
|
radioStreaming.value = "streaming";
|
|
radioStreaming.checked = getTTSMode() === "streaming";
|
|
modeStreaming.appendChild(radioStreaming);
|
|
modeStreaming.appendChild(document.createTextNode("Streaming"));
|
|
|
|
radioBuffered.addEventListener("change", () => { if (radioBuffered.checked) setTTSMode("buffered"); });
|
|
radioStreaming.addEventListener("change", () => { if (radioStreaming.checked) setTTSMode("streaming"); });
|
|
|
|
modeControl.appendChild(modeLabel);
|
|
modeControl.appendChild(modeBuffered);
|
|
modeControl.appendChild(modeStreaming);
|
|
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = getUIText("playText");
|
|
playButton.className = "tts-play-btn";
|
|
|
|
const resultDiv = document.createElement("div");
|
|
resultDiv.id = "tts-result";
|
|
resultDiv.className = "tts-result";
|
|
|
|
// Content container with padding
|
|
const contentContainer = document.createElement("div");
|
|
contentContainer.className = "tts-modal-content";
|
|
contentContainer.appendChild(textArea);
|
|
contentContainer.appendChild(voiceSelection);
|
|
contentContainer.appendChild(speedControl);
|
|
contentContainer.appendChild(modeControl);
|
|
contentContainer.appendChild(playButton);
|
|
contentContainer.appendChild(resultDiv);
|
|
|
|
article.appendChild(header);
|
|
article.appendChild(contentContainer);
|
|
modal.appendChild(article);
|
|
|
|
// Safari <15.4 lacks native <dialog>; fall back to a positioned overlay.
|
|
try {
|
|
modal.showModal();
|
|
} catch (e) {
|
|
console.warn("dialog.showModal() unsupported, using fallback overlay:", e);
|
|
modal.setAttribute("open", "");
|
|
modal.style.position = "fixed";
|
|
modal.style.top = "50%";
|
|
modal.style.left = "50%";
|
|
modal.style.transform = "translate(-50%, -50%)";
|
|
}
|
|
|
|
// Play button handler
|
|
playButton.onclick = async () => {
|
|
// Prime iOS Safari audio output before any async work (lost gesture fix).
|
|
primeAudioPlayback();
|
|
|
|
const text = textArea.value.trim();
|
|
if (!text) {
|
|
alert(getUIText("pleaseEnterText"));
|
|
return;
|
|
}
|
|
|
|
const selectedRadio = document.querySelector('input[name="tts-voice"]:checked');
|
|
if (!selectedRadio) {
|
|
alert("Please select a voice first");
|
|
return;
|
|
}
|
|
const selectedValue = selectedRadio.value;
|
|
const speed = parseFloat(speedSlider.value);
|
|
|
|
// Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")
|
|
// Normalize legacy/qwen entries so stale vault data doesn't 503.
|
|
let model = 'tts-1-f5';
|
|
let voice = selectedValue;
|
|
if (selectedValue.includes(':')) {
|
|
const parts = selectedValue.split(':');
|
|
model = parts[0];
|
|
voice = parts[1];
|
|
}
|
|
({ model, voice } = normalizeModelVoice(model, voice));
|
|
|
|
try {
|
|
const currentMode = getTTSMode();
|
|
playButton.textContent = currentMode === "streaming" ? "Streaming..." : getUIText("processingText");
|
|
playButton.disabled = true;
|
|
|
|
const result = await handleTTS(text, voice, speed, model);
|
|
if (!result.audio) return;
|
|
|
|
const isStreaming = result.streamed === true;
|
|
console.log(`TTS result: streamed=${isStreaming}`);
|
|
|
|
resultDiv.classList.add("show");
|
|
resultDiv.innerHTML = "";
|
|
|
|
// Use the audio element from the result directly (critical for streaming -
|
|
// it's already playing via MediaSource, creating a new Audio would lose the stream)
|
|
const audio = result.audio;
|
|
|
|
// For buffered mode, the audio isn't playing yet, so set it up
|
|
if (!isStreaming) {
|
|
audio.preload = "auto";
|
|
}
|
|
|
|
// Create custom control container
|
|
const controlsContainer = document.createElement("div");
|
|
controlsContainer.className = "tts-audio-controls";
|
|
|
|
// Create play/pause button
|
|
const playPauseBtn = document.createElement("button");
|
|
playPauseBtn.textContent = isStreaming ? "⏸️ Streaming..." : "⏸️ Pause";
|
|
|
|
// Create progress bar
|
|
const progressContainer = document.createElement("div");
|
|
progressContainer.className = "tts-progress-container";
|
|
|
|
const progressBar = document.createElement("div");
|
|
progressBar.className = "tts-progress-bar";
|
|
progressContainer.appendChild(progressBar);
|
|
|
|
// Create time display
|
|
const timeDisplay = document.createElement("span");
|
|
timeDisplay.textContent = "0:00 / 0:00";
|
|
timeDisplay.className = "tts-time-display";
|
|
|
|
// Create download button (disabled until stream completes)
|
|
const downloadBtn = document.createElement("button");
|
|
downloadBtn.className = "download-btn";
|
|
let downloadBlobUrl = result.blobUrl;
|
|
|
|
if (isStreaming && !result.blob) {
|
|
downloadBtn.textContent = "💾 Loading...";
|
|
downloadBtn.disabled = true;
|
|
} else {
|
|
downloadBtn.textContent = "💾 Download";
|
|
}
|
|
|
|
downloadBtn.onclick = async () => {
|
|
if (!downloadBlobUrl) return;
|
|
try {
|
|
const filename = await generateTitleForTTS(text);
|
|
downloadAudio(downloadBlobUrl, `${filename}.mp3`);
|
|
} catch (error) {
|
|
console.error("Error generating filename:", error);
|
|
downloadAudio(downloadBlobUrl, "tts-audio.mp3");
|
|
}
|
|
};
|
|
|
|
// Assemble controls
|
|
controlsContainer.appendChild(playPauseBtn);
|
|
controlsContainer.appendChild(progressContainer);
|
|
controlsContainer.appendChild(timeDisplay);
|
|
controlsContainer.appendChild(downloadBtn);
|
|
resultDiv.appendChild(controlsContainer);
|
|
|
|
// Track state
|
|
let isPlaying = !audio.paused;
|
|
let streamDone = !isStreaming; // buffered mode is already "done"
|
|
|
|
const formatTime = (seconds) => {
|
|
if (!isFinite(seconds)) return "?:??";
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const updateProgress = () => {
|
|
if (audio.duration && isFinite(audio.duration)) {
|
|
const progress = (audio.currentTime / audio.duration) * 100;
|
|
progressBar.style.width = `${progress}%`;
|
|
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
|
} else if (isStreaming) {
|
|
// During streaming, duration may not be known yet
|
|
timeDisplay.textContent = `${formatTime(audio.currentTime)} / streaming...`;
|
|
}
|
|
};
|
|
|
|
const updatePlayPauseBtn = () => {
|
|
if (isPlaying) {
|
|
playPauseBtn.textContent = streamDone ? "⏸️ Pause" : "⏸️ Streaming...";
|
|
} else {
|
|
playPauseBtn.textContent = "▶️ Play";
|
|
}
|
|
};
|
|
|
|
playPauseBtn.onclick = () => {
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
isPlaying = false;
|
|
} else {
|
|
audio.play();
|
|
isPlaying = true;
|
|
}
|
|
updatePlayPauseBtn();
|
|
};
|
|
|
|
// Progress bar click handler
|
|
progressContainer.onclick = (e) => {
|
|
if (audio.duration && isFinite(audio.duration)) {
|
|
const rect = progressContainer.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left;
|
|
const clickRatio = clickX / rect.width;
|
|
audio.currentTime = clickRatio * audio.duration;
|
|
}
|
|
};
|
|
|
|
// Audio event listeners
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
if (isFinite(audio.duration)) {
|
|
timeDisplay.textContent = `0:00 / ${formatTime(audio.duration)}`;
|
|
}
|
|
});
|
|
|
|
audio.addEventListener('timeupdate', updateProgress);
|
|
|
|
audio.addEventListener('ended', () => {
|
|
playPauseBtn.textContent = "▶️ Replay";
|
|
isPlaying = false;
|
|
progressBar.style.width = '0%';
|
|
audio.currentTime = 0;
|
|
});
|
|
|
|
audio.addEventListener('error', (e) => {
|
|
console.error('Audio playback error:', e);
|
|
playPauseBtn.textContent = "❌ Error";
|
|
playPauseBtn.disabled = true;
|
|
const errorMsg = document.createElement('div');
|
|
errorMsg.className = "tts-error-message";
|
|
errorMsg.textContent = 'Audio failed to load. Please try again or check your connection.';
|
|
controlsContainer.appendChild(errorMsg);
|
|
});
|
|
|
|
audio.addEventListener('abort', (e) => {
|
|
console.warn('Audio loading was aborted:', e);
|
|
if (isPlaying) {
|
|
playPauseBtn.textContent = "▶️ Retry";
|
|
isPlaying = false;
|
|
}
|
|
});
|
|
|
|
audio.addEventListener('stalled', (e) => {
|
|
console.warn('Audio loading stalled:', e);
|
|
if (!streamDone) {
|
|
playPauseBtn.textContent = "⏳ Buffering...";
|
|
}
|
|
});
|
|
|
|
// Wait for stream to complete in background (doesn't block UI)
|
|
result.done.then(({ blob, blobUrl }) => {
|
|
streamDone = true;
|
|
downloadBlobUrl = blobUrl;
|
|
downloadBtn.textContent = "💾 Download";
|
|
downloadBtn.disabled = false;
|
|
console.log("Stream done, download ready");
|
|
// Update button text now that stream is complete
|
|
if (isPlaying) {
|
|
playPauseBtn.textContent = "⏸️ Pause";
|
|
}
|
|
}).catch((err) => {
|
|
console.error("Stream failed:", err);
|
|
downloadBtn.textContent = "💾 Failed";
|
|
});
|
|
|
|
// For buffered mode, auto-play; for streaming, it's already playing
|
|
if (!isStreaming) {
|
|
audio.play().then(() => {
|
|
isPlaying = true;
|
|
updatePlayPauseBtn();
|
|
}).catch((e) => {
|
|
console.warn('Auto-play failed:', e.name, e.message);
|
|
isPlaying = false;
|
|
playPauseBtn.textContent = "▶️ Play";
|
|
if (e.name === 'NotSupportedError') {
|
|
playPauseBtn.textContent = "❌ Unsupported";
|
|
playPauseBtn.disabled = true;
|
|
}
|
|
});
|
|
} else {
|
|
// Streaming mode - audio is already playing
|
|
updatePlayPauseBtn();
|
|
}
|
|
|
|
// Re-enable the generate button now that controls are shown
|
|
playButton.textContent = getUIText("playText");
|
|
playButton.disabled = false;
|
|
} catch (error) {
|
|
alert(getUIText("ttsFailed", { error: error.message }));
|
|
playButton.textContent = getUIText("playText");
|
|
playButton.disabled = false;
|
|
}
|
|
};
|
|
|
|
// Focus the textarea
|
|
setTimeout(() => {
|
|
textArea.focus();
|
|
}, 100);
|
|
|
|
// Expose globally
|
|
window.closeTTSModal = () => {
|
|
modal.close();
|
|
document.body.removeChild(modal);
|
|
};
|
|
|
|
// Clean up when closed
|
|
modal.addEventListener("close", () => {
|
|
if (modal.parentNode) {
|
|
document.body.removeChild(modal);
|
|
}
|
|
});
|
|
}
|