mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add tts mini app
This commit is contained in:
+16
-32
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user