Add tts mini app

This commit is contained in:
2025-07-01 16:10:45 +10:00
parent 9ec4a0d996
commit dbf8641a71
4 changed files with 848 additions and 33 deletions
+16 -32
View File
@@ -19,44 +19,19 @@ class MiniAppsListView(TemplateView):
'color': '#3498db'
},
{
'name': 'TTS (Text-to-Speech)',
'description': 'Convert any text or markdown content to high-quality speech audio.',
'url': 'mini-apps-tts',
'thumbnail': 'https://images.unsplash.com/photo-1589254065878-42c9da997008?w=400&h=300&fit=crop',
'icon': 'fas fa-volume-up',
'color': '#8e44ad'
},{
'name': 'Weather Dashboard',
'description': 'Real-time weather information with beautiful visualizations and forecasts.',
'url': '#',
'thumbnail': 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=400&h=300&fit=crop',
'icon': 'fas fa-cloud-sun',
'color': '#e74c3c'
},
{
'name': 'Music Player',
'description': 'Stream and organize your favorite music with an elegant interface.',
'url': '#',
'thumbnail': 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=300&fit=crop',
'icon': 'fas fa-music',
'color': '#9b59b6'
},
{
'name': 'Task Manager',
'description': 'Organize your tasks and boost productivity with smart scheduling.',
'url': '#',
'thumbnail': 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=400&h=300&fit=crop',
'icon': 'fas fa-tasks',
'color': '#2ecc71'
},
{
'name': 'Code Editor',
'description': 'A lightweight code editor with syntax highlighting and themes.',
'url': '#',
'thumbnail': 'https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400&h=300&fit=crop',
'icon': 'fas fa-code',
'color': '#f39c12'
},
{
'name': 'Calculator',
'description': 'Scientific calculator with advanced mathematical functions.',
'url': '#',
'thumbnail': 'https://images.unsplash.com/photo-1611224923853-80b023f02d71?w=400&h=300&fit=crop',
'icon': 'fas fa-calculator',
'color': '#1abc9c'
}
]
@@ -71,3 +46,12 @@ class ImageGalleryView(TemplateView):
context = super().get_context_data(**kwargs)
# Add any context data needed for the image gallery
return context
class TTSView(TemplateView):
template_name = 'links/mini_apps/tts.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = 'Text-to-Speech'
return context
+783
View File
@@ -0,0 +1,783 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Text-to-Speech" %} - {% trans "Mini Apps" %}{% endblock %}
{% block extra_css %}
<style>
.tts-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.tts-header {
text-align: center;
margin-bottom: 3rem;
}
.tts-title {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #8e44ad 0%, #3498db 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.tts-subtitle {
color: #6b7280;
font-size: 1.125rem;
}
.input-section {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 2rem;
margin-bottom: 2rem;
}
.text-input {
width: 100%;
min-height: 200px;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 14px;
line-height: 1.6;
resize: vertical;
transition: border-color 0.2s ease;
}
.text-input:focus {
outline: none;
border-color: #8e44ad;
box-shadow: 0 0 0 3px rgba(142, 68, 173, 0.1);
}
.input-controls {
display: flex;
justify-content: between;
align-items: center;
margin-top: 1rem;
gap: 1rem;
}
.char-counter {
color: #6b7280;
font-size: 0.875rem;
margin-left: auto;
}
.speak-btn {
background: linear-gradient(135deg, #8e44ad 0%, #9b59b6 100%);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 8px rgba(142, 68, 173, 0.3);
}
.speak-btn:hover {
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
transform: translateY(-1px);
box-shadow: 0 6px 12px rgba(142, 68, 173, 0.4);
}
.speak-btn:active {
transform: translateY(0);
}
.speak-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.clear-btn {
background: #6b7280;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: #4b5563;
}
/* Enhanced Audio Player Styles - Reused from post detail */
.audio-player {
background: linear-gradient(145deg, #f8fafc 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 1.5rem;
}
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
.play-pause-btn {
width: 48px;
height: 48px;
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border: none;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.play-pause-btn:hover {
background: linear-gradient(145deg, #334155 0%, #1e293b 100%);
transform: translateY(-1px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
}
.play-pause-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.play-pause-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stop-btn {
width: 36px;
height: 36px;
background: linear-gradient(145deg, #dc2626 0%, #991b1b 100%);
border: none;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 3px 6px rgba(220, 38, 38, 0.3);
}
.stop-btn:hover {
background: linear-gradient(145deg, #ef4444 0%, #dc2626 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4);
}
.stop-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
}
.speed-btn {
width: 44px;
height: 32px;
background: linear-gradient(145deg, #059669 0%, #047857 100%);
border: none;
border-radius: 16px;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
font-size: 11px;
font-weight: 700;
font-family: 'Monaco', 'Menlo', monospace;
}
.speed-btn:hover {
background: linear-gradient(145deg, #10b981 0%, #059669 100%);
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(5, 150, 105, 0.4);
}
.speed-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
}
.download-btn {
width: 36px;
height: 36px;
background: linear-gradient(145deg, #2563eb 0%, #1d4ed8 100%);
border: none;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 3px 6px rgba(37, 99, 235, 0.3);
}
.download-btn:hover {
background: linear-gradient(145deg, #3b82f6 0%, #2563eb 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.4);
}
.download-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.3);
}
.download-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.progress-container {
position: relative;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #8e44ad 0%, #9b59b6 100%);
border-radius: 4px;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(142, 68, 173, 0.3);
will-change: width;
}
.seek-slider {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
}
.audio-time {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 14px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.audio-status {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-ready { background: #ddd6fe; color: #8e44ad; }
.status-generating { background: #fef3c7; color: #d97706; }
.status-playing { background: #dcfce7; color: #16a34a; }
.status-paused { background: #fed7aa; color: #ea580c; }
.status-stopped { background: #f3f4f6; color: #6b7280; }
.status-finished { background: #dbeafe; color: #2563eb; }
.status-error { background: #fecaca; color: #dc2626; }
.example-texts {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.example-btn {
background: #f3f4f6;
border: 1px solid #d1d5db;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.example-btn:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
@media (max-width: 768px) {
.tts-container {
padding: 1rem;
}
.tts-title {
font-size: 2rem;
}
.input-controls {
flex-direction: column;
align-items: stretch;
}
.char-counter {
margin-left: 0;
text-align: center;
}
}
</style>
{% endblock %}
{% block content %}
<div class="tts-container">
<!-- Header -->
<div class="tts-header">
<h1 class="tts-title">{% trans "Text-to-Speech" %}</h1>
<p class="tts-subtitle">{% trans "Convert any text or markdown content to high-quality speech audio" %}</p>
</div>
<!-- Input Section -->
<div class="input-section">
<label for="text-input" class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Enter your text or markdown content:" %}
</label>
<textarea
id="text-input"
class="text-input"
placeholder="{% trans 'Type or paste your text here... Markdown formatting is supported!' %}"
maxlength="3000"></textarea>
<div class="input-controls">
<button id="speak-btn" class="speak-btn" onclick="generateSpeech()">
<i class="fas fa-volume-up mr-2"></i>
{% trans "Speak" %}
</button>
<button class="clear-btn" onclick="clearText()">
{% trans "Clear" %}
</button>
<div id="char-counter" class="char-counter">0 / 3000</div>
</div>
<!-- Example texts -->
<div class="example-texts">
<button class="example-btn" onclick="insertExample(1)">{% trans "Example 1" %}</button>
<button class="example-btn" onclick="insertExample(2)">{% trans "Example 2" %}</button>
<button class="example-btn" onclick="insertExample(3)">{% trans "Example 3" %}</button>
</div>
</div>
<!-- Audio Player -->
<div id="audio-player" class="audio-player" style="display: none;">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-800">{% trans "Audio Player" %}</h4>
<div id="audio-status" class="audio-status status-ready">{% trans "Ready" %}</div>
</div>
<!-- Audio Controls -->
<div class="space-y-4">
<audio id="tts-audio" class="hidden" preload="none">
Your browser does not support the audio element.
</audio>
<!-- Progress Bar -->
<div class="progress-container">
<div id="progress-bar" class="progress-bar" style="width: 0%"></div>
<input id="seek-slider" type="range" min="0" max="100" value="0" class="seek-slider">
</div>
<!-- Control Buttons and Time -->
<div class="flex items-center justify-between">
<div class="audio-controls">
<button id="play-pause-btn" onclick="togglePlayPause()"
class="play-pause-btn"
disabled>
<svg id="play-icon" class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
</svg>
<svg id="pause-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</button>
<button onclick="stopAudio()" class="stop-btn">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
</svg>
</button>
<button id="speed-btn" onclick="toggleSpeed()" class="speed-btn" title="{% trans 'Playback Speed' %}">
1x
</button>
<button id="download-btn" onclick="downloadAudio()" class="download-btn" title="{% trans 'Download Audio' %}" disabled>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
</div>
<div class="audio-time">
<span id="current-time">0:00</span> / <span id="total-time">0:00</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// TTS Functionality
let currentAudio = null;
let currentAudioBlob = null; // Store the audio blob for download
let isLoading = false;
let playbackSpeeds = [1, 1.25, 1.5, 2];
let currentSpeedIndex = 0;
let progressUpdateInterval = null;
// Example texts
const examples = {
1: `# Welcome to Text-to-Speech
This is a **powerful** text-to-speech application that can convert your text to natural-sounding audio.
## Features:
- Support for markdown formatting
- Multiple playback speeds
- High-quality speech synthesis
- Easy to use interface`,
2: `Once upon a time, in a land far away, there lived a curious developer who discovered the magic of text-to-speech technology. With just a few lines of code, they could bring words to life and make them dance through the air as beautiful sounds.
The developer was amazed by how technology could bridge the gap between written and spoken communication.`,
3: `胖娃胖嘟嘟,骑马上成都,成都又好耍。胖娃骑白马,白马跳得高。胖娃耍关刀,关刀耍得圆。胖娃吃汤圆。`
};
// Character counter
document.getElementById('text-input').addEventListener('input', function() {
const text = this.value;
const counter = document.getElementById('char-counter');
counter.textContent = `${text.length} / 3000`;
if (text.length > 2500) {
counter.style.color = '#dc2626';
} else if (text.length > 2000) {
counter.style.color = '#d97706';
} else {
counter.style.color = '#6b7280';
}
});
function insertExample(num) {
const textInput = document.getElementById('text-input');
textInput.value = examples[num];
textInput.dispatchEvent(new Event('input'));
}
function clearText() {
const textInput = document.getElementById('text-input');
textInput.value = '';
textInput.dispatchEvent(new Event('input'));
textInput.focus();
}
function generateSpeech() {
const textInput = document.getElementById('text-input');
const text = textInput.value.trim();
if (!text) {
alert('{% trans "Please enter some text to convert to speech." %}');
return;
}
if (isLoading) return;
isLoading = true;
const speakBtn = document.getElementById('speak-btn');
const audioPlayer = document.getElementById('audio-player');
const status = document.getElementById('audio-status');
const playBtn = document.getElementById('play-pause-btn');
// Show audio player and update status
audioPlayer.style.display = 'block';
status.textContent = '{% trans "Generating..." %}';
status.className = 'audio-status status-generating';
speakBtn.disabled = true;
speakBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{% trans "Generating..." %}';
playBtn.disabled = true;
fetch('{% url "tts-api" %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text
})
})
.then(response => {
if (!response.ok) {
throw new Error('TTS generation failed');
}
return response.blob();
})
.then(blob => {
// Store the blob for download functionality
currentAudioBlob = blob;
const audioUrl = URL.createObjectURL(blob);
const audio = document.getElementById('tts-audio');
audio.src = audioUrl;
currentAudio = audio;
audio.addEventListener('loadedmetadata', function() {
status.textContent = '{% trans "Ready" %}';
status.className = 'audio-status status-ready';
playBtn.disabled = false;
document.getElementById('download-btn').disabled = false; // Enable download button
updateTotalTime();
setupAudioEvents();
});
audio.addEventListener('error', function() {
status.textContent = '{% trans "Error" %}';
status.className = 'audio-status status-error';
playBtn.disabled = true;
document.getElementById('download-btn').disabled = true; // Disable download button on error
});
audio.load();
})
.catch(error => {
console.error('TTS Error:', error);
status.textContent = '{% trans "Failed" %}';
status.className = 'audio-status status-error';
playBtn.disabled = true;
document.getElementById('download-btn').disabled = true; // Disable download button on error
})
.finally(() => {
isLoading = false;
speakBtn.disabled = false;
speakBtn.innerHTML = '<i class="fas fa-volume-up mr-2"></i>{% trans "Speak" %}';
});
}
function setupAudioEvents() {
if (!currentAudio) return;
// Remove existing event listeners to avoid duplicates
currentAudio.removeEventListener('timeupdate', updateProgress);
currentAudio.removeEventListener('ended', onAudioEnded);
// Add event listeners
currentAudio.addEventListener('timeupdate', updateProgress);
currentAudio.addEventListener('ended', onAudioEnded);
currentAudio.addEventListener('play', startProgressUpdates);
currentAudio.addEventListener('pause', stopProgressUpdates);
// Set initial playback rate
currentAudio.playbackRate = playbackSpeeds[currentSpeedIndex];
// Seek functionality
const seekSlider = document.getElementById('seek-slider');
seekSlider.removeEventListener('input', handleSeek); // Remove existing listener
seekSlider.addEventListener('input', handleSeek);
}
function handleSeek() {
if (currentAudio) {
const seekTime = (this.value / 100) * currentAudio.duration;
currentAudio.currentTime = seekTime;
updateProgress(); // Immediate update for responsive seeking
}
}
function startProgressUpdates() {
stopProgressUpdates(); // Clear any existing interval
progressUpdateInterval = setInterval(updateProgress, 50); // Update every 50ms for smooth animation
}
function stopProgressUpdates() {
if (progressUpdateInterval) {
clearInterval(progressUpdateInterval);
progressUpdateInterval = null;
}
}
function togglePlayPause() {
if (!currentAudio) return;
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('audio-status');
if (currentAudio.paused) {
currentAudio.play();
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
status.textContent = '{% trans "Playing" %}';
status.className = 'audio-status status-playing';
startProgressUpdates();
} else {
currentAudio.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Paused" %}';
status.className = 'audio-status status-paused';
stopProgressUpdates();
}
}
function stopAudio() {
if (!currentAudio) return;
currentAudio.pause();
currentAudio.currentTime = 0;
stopProgressUpdates();
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('audio-status');
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Stopped" %}';
status.className = 'audio-status status-stopped';
updateProgress();
}
function toggleSpeed() {
if (!currentAudio) return;
// Cycle through speeds
currentSpeedIndex = (currentSpeedIndex + 1) % playbackSpeeds.length;
const newSpeed = playbackSpeeds[currentSpeedIndex];
// Update audio playback rate
currentAudio.playbackRate = newSpeed;
// Update button text
const speedBtn = document.getElementById('speed-btn');
speedBtn.textContent = newSpeed + 'x';
// Visual feedback
speedBtn.style.transform = 'scale(1.1)';
setTimeout(() => {
speedBtn.style.transform = '';
}, 150);
}
function updateProgress() {
if (!currentAudio) return;
const progress = (currentAudio.currentTime / currentAudio.duration) * 100;
document.getElementById('progress-bar').style.width = progress + '%';
document.getElementById('seek-slider').value = progress;
document.getElementById('current-time').textContent = formatTime(currentAudio.currentTime);
}
function updateTotalTime() {
if (!currentAudio) return;
document.getElementById('total-time').textContent = formatTime(currentAudio.duration);
}
function onAudioEnded() {
stopProgressUpdates();
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('audio-status');
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Finished" %}';
status.className = 'audio-status status-finished';
// Reset speed to 1x when audio ends
currentSpeedIndex = 0;
const speedBtn = document.getElementById('speed-btn');
speedBtn.textContent = '1x';
if (currentAudio) {
currentAudio.playbackRate = 1;
}
}
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return mins + ':' + (secs < 10 ? '0' : '') + secs;
}
function downloadAudio() {
if (!currentAudioBlob) {
alert('{% trans "No audio available for download." %}');
return;
}
// Create a temporary download link
const downloadUrl = URL.createObjectURL(currentAudioBlob);
const link = document.createElement('a');
link.href = downloadUrl;
// Generate filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `tts-audio-${timestamp}.mp3`;
// Trigger download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the URL
setTimeout(() => {
URL.revokeObjectURL(downloadUrl);
}, 100);
// Visual feedback
const downloadBtn = document.getElementById('download-btn');
downloadBtn.style.transform = 'scale(1.1)';
setTimeout(() => {
downloadBtn.style.transform = '';
}, 150);
}
// Auto-focus on text input when page loads
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('text-input').focus();
});
</script>
{% endblock %}
+4
View File
@@ -51,6 +51,10 @@ urlpatterns = [
# Mini Apps
path('ui/mini-apps/', mini_apps_views.MiniAppsListView.as_view(), name='mini-apps-list'),
path('ui/mini-apps/image-gallery/', mini_apps_views.ImageGalleryView.as_view(), name='mini-apps-image-gallery'),
path('ui/mini-apps/tts/', mini_apps_views.TTSView.as_view(), name='mini-apps-tts'),
# TTS API
path('ui/tts-api/', views.generate_tts_api, name='tts-api'),
# API Documentation
path('ui/api-docs/',
+45 -1
View File
@@ -93,7 +93,7 @@ class LinkListView(ListView):
# Add pinned mini apps with random colors
mini_apps = [
{'name': 'Image Gallery', 'description': 'Browse Images', 'url': 'mini-apps-image-gallery'},
{'name': 'Coming Soon', 'description': 'New App', 'url': '#'},
{'name': 'TTS', 'description': 'Convert text to speech', 'url': 'mini-apps-tts'},
{'name': 'Coming Soon', 'description': 'New App', 'url': '#'}
]
context['mini_apps'] = [
@@ -543,6 +543,50 @@ def generate_tts(request, post_id):
except Exception as e:
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
@require_http_methods(["POST"])
def generate_tts_api(request):
"""General TTS API that accepts text content"""
try:
import json
# Get text from request body
if request.content_type == 'application/json':
data = json.loads(request.body)
text_content = data.get('text', '')
else:
text_content = request.POST.get('text', '')
if not text_content.strip():
return JsonResponse({'error': 'No text content provided'}, status=400)
# Clean the text content - remove markdown and HTML
clean_text = clean_text_for_tts(text_content)
if not clean_text.strip():
return JsonResponse({'error': 'No valid text content to convert'}, status=400)
# Call TTS API
import urllib.parse
encoded_text = urllib.parse.quote(clean_text)
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&haha=hahaJunv"
try:
response = requests.get(tts_url, timeout=30)
response.raise_for_status()
# Return the audio file
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
audio_response['Content-Disposition'] = f'inline; filename="tts_audio.mp3"'
return audio_response
except requests.RequestException as e:
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON data'}, status=400)
except Exception as e:
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
def clean_text_for_tts(content):
"""Clean markdown and HTML from text for TTS"""
# Convert markdown to HTML first