diff --git a/data/db.sqlite3 b/data/db.sqlite3 index a7f902b..954793e 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/templates/links/post_detail.html b/links/templates/links/post_detail.html index dc1b439..39a96af 100644 --- a/links/templates/links/post_detail.html +++ b/links/templates/links/post_detail.html @@ -76,6 +76,174 @@ .think-icon.expanded { transform: rotate(180deg); } + + /* Enhanced Audio Player Styles */ + .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); + } + + .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); + } + + .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, #7c3aed 0%, #a855f7 100%); + border-radius: 4px; + transition: width 0.1s ease; + box-shadow: 0 1px 3px rgba(124, 58, 237, 0.3); + } + + .seek-slider { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + margin: 0; + } + + .seek-slider::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + background: #7c3aed; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .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: #7c3aed; } + .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; } {% endblock %} @@ -120,6 +288,14 @@
+ @@ -191,6 +367,57 @@
+ + + @@ -242,5 +469,213 @@ document.addEventListener('DOMContentLoaded', function() { }); }); }); + +// TTS Functionality +let currentAudio = null; +let isLoading = false; +let playbackSpeeds = [1, 1.25, 1.5, 2]; +let currentSpeedIndex = 0; + +function toggleTTS() { + const player = document.getElementById('tts-player'); + const buttonText = document.getElementById('tts-button-text'); + + if (player.classList.contains('hidden')) { + player.classList.remove('hidden'); + buttonText.textContent = '{% trans "Hide Player" %}'; + + if (!currentAudio) { + loadTTSAudio(); + } + } else { + player.classList.add('hidden'); + buttonText.textContent = '{% trans "Listen" %}'; + + if (currentAudio) { + currentAudio.pause(); + } + } +} + +function loadTTSAudio() { + if (isLoading) return; + + isLoading = true; + const status = document.getElementById('tts-status'); + const playBtn = document.getElementById('play-pause-btn'); + + status.textContent = '{% trans "Generating..." %}'; + status.className = 'audio-status status-generating'; + playBtn.disabled = true; + + fetch('{% url "post-tts" post.pk %}', { + method: 'POST', + headers: { + 'X-CSRFToken': '{{ csrf_token }}', + 'Content-Type': 'application/json', + }, + }) + .then(response => { + if (!response.ok) { + throw new Error('TTS generation failed'); + } + return response.blob(); + }) + .then(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; + updateTotalTime(); + setupAudioEvents(); + }); + + audio.addEventListener('error', function() { + status.textContent = '{% trans "Error" %}'; + status.className = 'audio-status status-error'; + playBtn.disabled = true; + }); + + audio.load(); + }) + .catch(error => { + console.error('TTS Error:', error); + status.textContent = '{% trans "Failed" %}'; + status.className = 'audio-status status-error'; + playBtn.disabled = true; + }) + .finally(() => { + isLoading = false; + }); +} + +function setupAudioEvents() { + if (!currentAudio) return; + + currentAudio.addEventListener('timeupdate', updateProgress); + currentAudio.addEventListener('ended', onAudioEnded); + + // Set initial playback rate + currentAudio.playbackRate = playbackSpeeds[currentSpeedIndex]; + + // Seek functionality + const seekSlider = document.getElementById('seek-slider'); + seekSlider.addEventListener('input', function() { + if (currentAudio) { + const seekTime = (this.value / 100) * currentAudio.duration; + currentAudio.currentTime = seekTime; + } + }); +} + +function togglePlayPause() { + if (!currentAudio) return; + + const playIcon = document.getElementById('play-icon'); + const pauseIcon = document.getElementById('pause-icon'); + const status = document.getElementById('tts-status'); + + if (currentAudio.paused) { + currentAudio.play(); + playIcon.classList.add('hidden'); + pauseIcon.classList.remove('hidden'); + status.textContent = '{% trans "Playing" %}'; + status.className = 'audio-status status-playing'; + } else { + currentAudio.pause(); + playIcon.classList.remove('hidden'); + pauseIcon.classList.add('hidden'); + status.textContent = '{% trans "Paused" %}'; + status.className = 'audio-status status-paused'; + } +} + +function stopAudio() { + if (!currentAudio) return; + + currentAudio.pause(); + currentAudio.currentTime = 0; + + const playIcon = document.getElementById('play-icon'); + const pauseIcon = document.getElementById('pause-icon'); + const status = document.getElementById('tts-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() { + const playIcon = document.getElementById('play-icon'); + const pauseIcon = document.getElementById('pause-icon'); + const status = document.getElementById('tts-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; +} {% endblock %} diff --git a/links/urls.py b/links/urls.py index 1bd2b89..9f8605a 100644 --- a/links/urls.py +++ b/links/urls.py @@ -41,6 +41,7 @@ urlpatterns = [ path('ui/posts//', post_views.PostDetailView.as_view(), name='post-detail'), path('ui/posts//edit/', post_views.PostUpdateView.as_view(), name='post-update'), path('ui/posts//delete/', post_views.PostDeleteView.as_view(), name='post-delete'), + path('ui/posts//tts/', views.generate_tts, name='post-tts'), # Collection slideshow path('ui/collections//slideshow/', diff --git a/links/views.py b/links/views.py index 539fb11..525fd5d 100644 --- a/links/views.py +++ b/links/views.py @@ -29,12 +29,10 @@ from wsgiref.util import FileWrapper from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.mixins import LoginRequiredMixin import requests +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt from bs4 import BeautifulSoup -from urllib.parse import urlparse -from rest_framework import viewsets, status -from rest_framework.response import Response -from rest_framework.pagination import PageNumberPagination -from .serializers import PageSerializer + from .page_views import ( PageListView, PageDetailView, PageCreateView, PageUpdateView, PageDeleteView, fetch_page_info, @@ -510,3 +508,63 @@ def export_database(request): class HelpView(TemplateView): template_name = 'links/help.html' + +@require_http_methods(["POST"]) +def generate_tts(request, post_id): + """Generate TTS audio for a post""" + try: + post = get_object_or_404(Post, pk=post_id) + + # Clean the post content - remove markdown and HTML + clean_text = clean_text_for_tts(post.content) + + if not clean_text.strip(): + return JsonResponse({'error': 'No 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="post_{post_id}_tts.mp3"' + return audio_response + + except requests.RequestException as e: + return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500) + + except Post.DoesNotExist: + return JsonResponse({'error': 'Post not found'}, status=404) + 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 + html = markdown.markdown(content) + + # Parse HTML and extract text + soup = BeautifulSoup(html, 'html.parser') + + # Remove script and style elements + for script in soup(["script", "style"]): + script.decompose() + + # Get text and clean it + text = soup.get_text() + + # Clean up whitespace and special characters + text = re.sub(r'\s+', ' ', text) # Multiple spaces to single + text = re.sub(r'\n+', '. ', text) # Multiple newlines to period + text = text.strip() + + # Limit text length (TTS services often have limits) + if len(text) > 3000: + text = text[:3000] + "..." + + return text diff --git a/uv.lock b/uv.lock index ee6f52a..ebad676 100644 --- a/uv.lock +++ b/uv.lock @@ -244,16 +244,16 @@ wheels = [ [[package]] name = "django" -version = "5.0.11" +version = "5.0.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/9c/614ef004c122cfdf9f2d291eab75e24ab94d24c0693a0ca0db44e9633dc3/Django-5.0.11.tar.gz", hash = "sha256:e7d98fa05ce09cb3e8d5ad6472fb602322acd1740bfdadc29c8404182d664f65", size = 10635979, upload-time = "2025-01-14T14:28:58.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/cc0205045386b5be8eecb15a95f290383d103f0db5f7e34f93dcc340d5b0/Django-5.0.14.tar.gz", hash = "sha256:29019a5763dbd48da1720d687c3522ef40d1c61be6fb2fad27ed79e9f655bc11", size = 10644306, upload-time = "2025-04-02T11:24:41.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/d7/da8d8f62f33c3cd45ad7e9cb456d7c95457d15ee26166d2d033f461aedf7/Django-5.0.11-py3-none-any.whl", hash = "sha256:09e8128f717266bf382d82ffa4933f13da05d82579abf008ede86acb15dec88b", size = 8185849, upload-time = "2025-01-14T14:28:46.345Z" }, + { url = "https://files.pythonhosted.org/packages/c0/93/eabde8789f41910845567ebbff5aacd52fd80e54c934ce15b83d5f552d2c/Django-5.0.14-py3-none-any.whl", hash = "sha256:e762bef8629ee704de215ebbd32062b84f4e56327eed412e5544f6f6eb1dfd74", size = 8185934, upload-time = "2025-04-02T11:24:36.888Z" }, ] [[package]] @@ -338,14 +338,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "22.0.0" +version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/88/e2f93c5738a4c1f56a458fc7a5b1676fc31dcdbb182bef6b40a141c17d66/gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63", size = 3639760, upload-time = "2024-04-16T22:58:19.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/97/6d610ae77b5633d24b69c2ff1ac3044e0e565ecbd1ec188f02c45073054c/gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", size = 84443, upload-time = "2024-04-16T22:58:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] [[package]] @@ -863,7 +863,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "boto3", specifier = ">=1.35.0" }, { name = "celery", specifier = ">=5.3.0" }, - { name = "django", specifier = "==5.0.11" }, + { name = "django", specifier = "==5.0.14" }, { name = "django-simplemde", specifier = "==0.1.4" }, { name = "django-tailwind", specifier = "==3.8.0" }, { name = "django-widget-tweaks", specifier = "==1.5.0" }, @@ -871,7 +871,7 @@ requires-dist = [ { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "flower", specifier = ">=2.0.0" }, { name = "fontawesome-free", specifier = "==5.15.3" }, - { name = "gunicorn", specifier = "==22.0.0" }, + { name = "gunicorn", specifier = "==23.0.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "markdown", specifier = "==3.7" }, { name = "pillow", specifier = "~=11.2.1" },