diff --git a/books/__init__.py b/books/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/books/admin.py b/books/admin.py new file mode 100644 index 0000000..631c637 --- /dev/null +++ b/books/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import EBook, EBookProgress + +admin.site.register(EBook) +admin.site.register(EBookProgress) diff --git a/books/apps.py b/books/apps.py new file mode 100644 index 0000000..a53388c --- /dev/null +++ b/books/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'books' diff --git a/books/migrations/0001_initial.py b/books/migrations/0001_initial.py new file mode 100644 index 0000000..355310c --- /dev/null +++ b/books/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.3 on 2026-03-19 09:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EBook', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('meta_ct', models.TextField()), + ('meta_iv', models.CharField(max_length=32)), + ('data_ct', models.TextField()), + ('data_iv', models.CharField(max_length=32)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['uploaded_at'], + }, + ), + migrations.CreateModel( + name='EBookProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scroll_fraction', models.FloatField(default=0.0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='books.ebook')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_progress', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'book')}, + }, + ), + ] diff --git a/books/migrations/0002_ebookbookmarks_ebookhighlights.py b/books/migrations/0002_ebookbookmarks_ebookhighlights.py new file mode 100644 index 0000000..d91f81a --- /dev/null +++ b/books/migrations/0002_ebookbookmarks_ebookhighlights.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.3 on 2026-03-19 11:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EBookBookmarks', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ct', models.TextField()), + ('iv', models.CharField(max_length=32)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.ebook')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_bookmarks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'book')}, + }, + ), + migrations.CreateModel( + name='EBookHighlights', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ct', models.TextField()), + ('iv', models.CharField(max_length=32)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='books.ebook')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_highlights', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'book')}, + }, + ), + ] diff --git a/books/migrations/__init__.py b/books/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/books/models.py b/books/models.py new file mode 100644 index 0000000..ef19afd --- /dev/null +++ b/books/models.py @@ -0,0 +1,58 @@ +from django.db import models +from django.contrib.auth.models import User + + +class EBook(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks') + meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename} + meta_iv = models.CharField(max_length=32) # hex IV for metadata + data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes + data_iv = models.CharField(max_length=32) # hex IV for EPUB data + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['uploaded_at'] + + def __str__(self): + return f"EBook #{self.pk} (user={self.user_id})" + + +class EBookProgress(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress') + book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') + scroll_fraction = models.FloatField(default=0.0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'book') + + def __str__(self): + return f"Progress book={self.book_id} user={self.user_id} {self.scroll_fraction:.2f}" + + +class EBookHighlights(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_highlights') + book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='highlights') + ct = models.TextField() # base64 AES-GCM ciphertext of JSON array + iv = models.CharField(max_length=32) # hex IV + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'book') + + def __str__(self): + return f"Highlights book={self.book_id} user={self.user_id}" + + +class EBookBookmarks(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_bookmarks') + book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='bookmarks') + ct = models.TextField() + iv = models.CharField(max_length=32) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'book') + + def __str__(self): + return f"Bookmarks book={self.book_id} user={self.user_id}" diff --git a/books/urls.py b/books/urls.py new file mode 100644 index 0000000..559f5fd --- /dev/null +++ b/books/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.book_list, name='book_list'), + path('upload/', views.upload_book, name='upload_book'), + path('/data/', views.get_book_data, name='get_book_data'), + path('/delete/', views.delete_book, name='delete_book'), + path('/progress/', views.save_progress, name='save_book_progress'), + path('/highlights/', views.book_highlights, name='book_highlights'), + path('/bookmarks/', views.book_bookmarks, name='book_bookmarks'), +] diff --git a/books/views.py b/books/views.py new file mode 100644 index 0000000..e39557c --- /dev/null +++ b/books/views.py @@ -0,0 +1,225 @@ +import base64 +import json + +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks + + +def _require_auth(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + return None + + +@require_http_methods(['GET']) +def book_list(request): + err = _require_auth(request) + if err: + return err + books = list( + request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at') + ) + for b in books: + b['uploaded_at'] = b['uploaded_at'].isoformat() + # Include saved scroll_fraction for each book + progress_map = { + p.book_id: p.scroll_fraction + for p in EBookProgress.objects.filter(user=request.user) + } + for b in books: + b['scroll_fraction'] = progress_map.get(b['id'], 0.0) + return JsonResponse(books, safe=False) + + +@csrf_exempt +@require_http_methods(['POST']) +def upload_book(request): + err = _require_auth(request) + if err: + return err + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + meta_ct = body.get('meta_ct', '') + meta_iv = body.get('meta_iv', '') + data_ct = body.get('data_ct', '') + data_iv = body.get('data_iv', '') + + if not all([meta_ct, meta_iv, data_ct, data_iv]): + return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400) + + # Enforce size limit: ciphertext is plaintext + 16-byte GCM tag + max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32 + try: + raw_size = len(base64.b64decode(data_ct)) + except Exception: + return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400) + + if raw_size > max_bytes: + return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400) + + book = EBook.objects.create( + user=request.user, + meta_ct=meta_ct, + meta_iv=meta_iv, + data_ct=data_ct, + data_iv=data_iv, + ) + return JsonResponse({'ok': True, 'id': book.id}) + + +@require_http_methods(['GET']) +def get_book_data(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv}) + + +@csrf_exempt +@require_http_methods(['POST']) +def delete_book(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + book.delete() + return JsonResponse({'ok': True}) + + +@csrf_exempt +@require_http_methods(['POST']) +def save_progress(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + scroll_fraction = float(body.get('scroll_fraction', 0.0)) + scroll_fraction = max(0.0, min(1.0, scroll_fraction)) + + progress, _ = EBookProgress.objects.get_or_create( + user=request.user, + book=book, + ) + progress.scroll_fraction = scroll_fraction + progress.save(update_fields=['scroll_fraction', 'updated_at']) + + return JsonResponse({'ok': True}) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def book_highlights(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + if request.method == 'GET': + try: + row = EBookHighlights.objects.get(user=request.user, book=book) + return JsonResponse({'ct': row.ct, 'iv': row.iv}) + except EBookHighlights.DoesNotExist: + return JsonResponse({'ct': None, 'iv': None}) + + # POST — upsert + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + ct = body.get('ct', '') + iv = body.get('iv', '') + if not ct or not iv: + return JsonResponse({'error': 'ct and iv required'}, status=400) + + # Size guard: highlights ≤ 700 KB base64 + try: + raw_size = len(base64.b64decode(ct)) + except Exception: + return JsonResponse({'error': 'invalid base64 in ct'}, status=400) + if raw_size > 700 * 1024: + return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400) + + row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book) + row.ct = ct + row.iv = iv + row.save(update_fields=['ct', 'iv', 'updated_at']) + return JsonResponse({'ok': True}) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def book_bookmarks(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + if request.method == 'GET': + try: + row = EBookBookmarks.objects.get(user=request.user, book=book) + return JsonResponse({'ct': row.ct, 'iv': row.iv}) + except EBookBookmarks.DoesNotExist: + return JsonResponse({'ct': None, 'iv': None}) + + # POST — upsert + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + ct = body.get('ct', '') + iv = body.get('iv', '') + if not ct or not iv: + return JsonResponse({'error': 'ct and iv required'}, status=400) + + # Size guard: bookmarks ≤ 100 KB base64 + try: + raw_size = len(base64.b64decode(ct)) + except Exception: + return JsonResponse({'error': 'invalid base64 in ct'}, status=400) + if raw_size > 100 * 1024: + return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400) + + row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book) + row.ct = ct + row.iv = iv + row.save(update_fields=['ct', 'iv', 'updated_at']) + return JsonResponse({'ok': True}) diff --git a/static/css/app.css b/static/css/app.css index 67b630d..7630fb2 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -32,8 +32,13 @@ --text-muted: var(--fg); } -/* inverted: black on bright */ +/* inverted: black on white */ body.bright-bg { + --bg: #fff; + --bg-card: #f5f5f5; + --bg-alt: #f0f0f0; + --bg-row: #fafafa; + --bg-row-alt: #f5f5f5; --fg: #000; --fg-muted: #000; --outline: #fff; @@ -314,6 +319,22 @@ a:hover { min-height: 200px; } +.sub-tabs { + border-bottom-color: transparent; + margin-bottom: 1rem; +} + +.sub-tabs .tab-btn { + font-size: 0.8rem; + padding: 0.3rem 1rem; + color: var(--text-muted); +} + +.sub-tabs .tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + /* ========================================================= SEARCH ========================================================= */ @@ -999,3 +1020,570 @@ body.dnd-mode .timer-display { .volume-num::-webkit-inner-spin-button, .volume-num::-webkit-outer-spin-button { display: none; } .volume-num { -moz-appearance: textfield; } + +/* ===== PODCAST COMPONENTS ===== */ + +.podcast-seek-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + flex: 1; + min-width: 0; +} + +.skip-btn { + font-size: 0.72rem; + opacity: 0.8; + white-space: nowrap; + padding: 2px 4px; +} +.skip-btn:hover { opacity: 1; } + +.speed-btns { + display: flex; + gap: 2px; + margin-left: 4px; + flex-shrink: 0; +} + +.speed-btn { + background: none; + border: 1px solid var(--border, #444); + color: var(--fg, #fff); + border-radius: 3px; + font-size: 0.68rem; + padding: 1px 4px; + cursor: pointer; + opacity: 0.6; + white-space: nowrap; +} +.speed-btn:hover { opacity: 1; } +.speed-btn.active { + opacity: 1; + border-color: var(--accent, #e63946); + color: var(--accent, #e63946); +} + +.seek-slider { + flex: 1; + accent-color: var(--accent, #e63946); + cursor: pointer; + height: 4px; +} + +.seek-time { + font-size: 0.75rem; + color: var(--text-muted, #888); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.podcast-toolbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 0 12px; + border-bottom: 1px solid var(--border, #333); + margin-bottom: 10px; + align-items: center; +} + +.podcast-pane { } + +.podcast-feed-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.podcast-feed-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--border, #222); +} + +.podcast-feed-info { + flex: 1; + min-width: 0; +} + +.podcast-feed-title { + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.podcast-feed-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.podcast-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.podcast-thumb-lg { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; +} + +.podcast-thumb-placeholder { + width: 48px; + height: 48px; + background: var(--surface, #1e1e2e); + border-radius: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.podcast-unplayed-badge { + background: var(--accent, #e63946); + color: #fff; + border-radius: 99px; + padding: 1px 7px; + font-size: 0.7rem; + margin-left: 6px; +} + +.episode-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.episode-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; + border-bottom: 1px solid var(--border, #222); +} + +.episode-item.episode-played { + opacity: 0.5; +} + +.episode-info { + flex: 1; + min-width: 0; +} + +.episode-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9rem; +} + +.episode-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.podcast-search-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.podcast-search-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--border, #222); +} + +.podcast-search-info { + flex: 1; + min-width: 0; +} + +.podcast-queue-ol { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.podcast-feed-header { + margin-bottom: 12px; +} + +.podcast-feed-header-inner { + display: flex; + align-items: center; + gap: 14px; +} + +/* Clickable episode title (episode list) */ +.ep-clickable { + cursor: pointer; +} +.ep-clickable:hover { + color: var(--accent, #e63946); + text-decoration: underline; +} + +/* Clickable episode title / feed name in the now-playing bar */ +.podcast-track-link, +.podcast-station-link { + cursor: pointer; +} +.podcast-track-link:hover, +.podcast-station-link:hover { + color: var(--accent, #e63946); + text-decoration: underline; +} + +/* ===== SIDEBAR ===== */ + +.sidebar-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 299; +} + +.sidebar { + position: fixed; + top: 0; + right: 0; + width: 400px; + max-width: 100vw; + /* stop above the now-playing bar */ + bottom: 72px; + background: var(--surface, #111); + border-left: 1px solid var(--border, #333); + z-index: 300; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.22s ease; + overflow: hidden; +} + +.sidebar.open { + transform: translateX(0); +} + +.sidebar-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border, #333); + flex-shrink: 0; +} + +.sidebar-title { + font-weight: bold; + font-size: 0.95rem; + line-height: 1.3; +} + +.sidebar-close { + flex-shrink: 0; + margin-top: -2px; +} + +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: 16px; + font-size: 0.88rem; + line-height: 1.6; +} + +/* Style links and basic HTML inside shownotes */ +.sidebar-body a { color: var(--accent, #e63946); } +.sidebar-body p { margin: 0 0 10px; } +.sidebar-body ul, .sidebar-body ol { margin: 0 0 10px; padding-left: 20px; } +.sidebar-body h1, .sidebar-body h2, .sidebar-body h3 { + font-size: 0.9rem; + margin: 12px 0 4px; +} + +@media (max-width: 600px) { + .sidebar { width: 100vw; } + .podcast-seek-bar { padding: 0 6px; } + .podcast-thumb { width: 40px; height: 40px; } + .podcast-thumb-lg { width: 60px; height: 60px; } + .podcast-feed-actions { flex-direction: column; } + .episode-actions { flex-direction: column; } +} + +/* ========================================================= + Ebook reader + book list + focus station + ========================================================= */ + +/* --- Drop zone --- */ +.book-drop-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 24px 16px; + text-align: center; + margin: 12px 0; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; +} +.book-drop-zone:hover, +.book-drop-zone.drag-over { + border-color: var(--accent); + background: rgba(255,255,255,0.04); +} +.book-drop-zone label { + color: var(--accent); + cursor: pointer; +} + +.book-key-bar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +/* --- Book list --- */ +.book-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} +.book-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + gap: 12px; +} +.book-item-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.book-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.book-author, +.book-progress { + font-size: 12px; +} +.book-item-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* --- PDF pages --- */ +.pdf-page-wrapper { + margin: 0 auto 1rem; + display: flex; + justify-content: center; +} + +.pdf-page { + display: block; + max-width: 100%; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); +} + +/* --- Reader overlay --- */ +.reader-overlay { + position: fixed; + top: var(--nav-h); + left: 0; + right: 0; + bottom: var(--bar-h); + background: var(--bg); + z-index: 200; + display: flex; + flex-direction: column; +} +.reader-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: 12px; +} +.reader-title { + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} +.reader-header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.reader-progress-wrap { + display: flex; + align-items: center; + gap: 4px; +} +.reader-content { + flex: 1; + overflow-y: scroll; + padding: 24px 16px; + line-height: 1.8; + font-family: Georgia, 'Times New Roman', serif; + font-size: 16px; + font-weight: 400; +} +.reader-content > * { + max-width: 65ch; + margin-left: auto; + margin-right: auto; +} +.reader-content p { + margin-bottom: 1em; +} +.reader-content h1, .reader-content h2, .reader-content h3 { + margin: 1.4em 0 0.6em; + font-family: var(--font); +} + +/* --- Focus station sidebar --- */ +.focus-preset-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; + margin: 10px 0; +} +.focus-preset-list li.focus-preset-active button { + border-color: var(--accent); + color: var(--accent); +} +.focus-custom-input { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +/* --- Table of contents sidebar --- */ +.toc-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; +} +.toc-entry { + display: block; + width: 100%; + text-align: left; + padding: 4px 0; + font-size: 13px; + white-space: normal; + word-break: break-word; +} +.toc-entry:hover { + color: var(--accent); +} + +/* Focus station pending (playing interrupted) */ +#focus-station-btn.focus-pending { + color: var(--accent); + animation: focus-pulse 1.4s ease-in-out infinite; +} +@keyframes focus-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ========================================================= + Reader feature additions + ========================================================= */ + +/* Reader themes */ +.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; } +.reader-theme-bright .reader-content { background:#fff; color:#111; } +.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; } + +/* Inline panels */ +.reader-settings-panel, .reader-search-bar { + display:flex; align-items:center; gap:12px; padding:8px 16px; + border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; background:var(--bg); +} +.reader-settings-panel input[type="range"] { width:80px; } + +/* PDF inner container — shares origin with canvas so text layer aligns exactly */ +.pdf-page-inner { position:relative; display:inline-block; line-height:0; } + +/* PDF text layer — sits flush over the canvas inside pdf-page-inner */ +.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; } +.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; } +.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); } +.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); } +.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); } + +/* PDF invert */ +#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); } + +/* PDF paginated */ +.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; } +.pdf-paginated .pdf-page-wrapper { margin:0; } + +/* Highlight popover */ +.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); } +.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; } +.hl-color-btn:hover { border-color:#fff; } +.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; } + +/* Highlight marks */ +.epub-highlight { border-radius:2px; cursor:pointer; } +.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); } +.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); } +.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); } +.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); } + +/* Search matches */ +mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; } +mark.reader-search-match.active { background:rgba(230,57,70,.7); } +#rs-search-count { font-size:12px; min-width:50px; } + +/* Bookmarks sidebar */ +.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); } + +/* Toast */ +.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; } +@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} } diff --git a/static/js/app.js b/static/js/app.js index ef940d1..1ed40e3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -15,6 +15,16 @@ let sseSource = null; let isPlaying = false; let currentPlayId = null; +// Podcast state +let podcastMode = false; +let currentEpisode = null; // {id, title, audioUrl, durationSeconds, feedId} +let seekSaveTimer = null; +let podcastFeeds = []; +let podcastQueue = []; +let podcastCurrentView = 'feeds'; +let podcastCurrentFeedId = null; +const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs + const audio = new Audio(); // --------------------------------------------------------------------------- @@ -75,9 +85,20 @@ function playStation(url, name, stationId) { } function stopPlayback(clearStation = true) { + // Save podcast progress before stopping + if (podcastMode && currentEpisode) { + savePodcastProgress(); + } + if (seekSaveTimer) { clearInterval(seekSaveTimer); seekSaveTimer = null; } + audio.pause(); audio.src = ''; + audio.ontimeupdate = null; isPlaying = false; + podcastMode = false; + + const seekBar = $('podcast-seek-bar'); + if (seekBar) seekBar.style.display = 'none'; if (sseSource) { sseSource.close(); @@ -91,11 +112,19 @@ function stopPlayback(clearStation = true) { stopPlaySession(); + const stationEl = $('now-playing-station'); + stationEl.classList.remove('podcast-station-link'); + stationEl.onclick = null; + + const trackEl = $('now-playing-track'); + trackEl.classList.remove('podcast-track-link'); + trackEl.onclick = null; + if (clearStation) { currentStation = null; currentTrack = ''; - $('now-playing-station').textContent = '— no station —'; - $('now-playing-track').textContent = ''; + stationEl.textContent = '— no station —'; + trackEl.textContent = ''; $('play-stop-btn').style.display = 'none'; } } @@ -142,8 +171,32 @@ window.addEventListener('beforeunload', () => { if (currentPlayId) { navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); } + if (podcastMode && currentEpisode) { + navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify({ + episode_id: currentEpisode.id, + position_seconds: Math.floor(audio.currentTime), + })); + } + // Flush cached encrypted payloads for reader data (encryption is async so we + // use pre-computed blobs stored by the debounced savers) + if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body); + if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body); + if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body); }); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && currentBookId) { + if (bookmarksDirty) saveBookmarks(); + if (highlightsDirty) saveHighlights(); + saveReaderProgress(); + } +}); + +// Cached beacon payloads — updated after each successful encrypt in save functions +let _lastBookmarkBeacon = null; +let _lastHighlightBeacon = null; +let _lastProgressBeacon = null; + // --------------------------------------------------------------------------- // Recommendations // --------------------------------------------------------------------------- @@ -935,22 +988,674 @@ function editNotes(pk, current) { // Tabs // --------------------------------------------------------------------------- +const TOP_TABS = ['radio', 'focus', 'podcasts', 'books']; +const RADIO_SUB_TABS = ['search', 'saved', 'history']; + function showTab(name) { - const panels = ['search', 'saved', 'history', 'focus']; - panels.forEach(p => { + TOP_TABS.forEach(p => { const panel = $(`tab-${p}`); if (panel) panel.style.display = (p === name) ? '' : 'none'; }); - document.querySelectorAll('.tab-btn').forEach((btn, i) => { - btn.classList.toggle('active', panels[i] === name); + document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { + btn.classList.toggle('active', TOP_TABS[i] === name); }); - if (name === 'saved') { - loadRecommendations(); + localStorage.setItem('diora_active_tab', name); + + if (name === 'podcasts') loadPodcastTab(); + if (name === 'books') loadBookList(); +} + +function showRadioTab(name) { + RADIO_SUB_TABS.forEach(p => { + const panel = $(`tab-${p}`); + if (panel) panel.style.display = (p === name) ? '' : 'none'; + }); + + document.querySelectorAll('#radio-sub-tabs .tab-btn').forEach((btn, i) => { + btn.classList.toggle('active', RADIO_SUB_TABS[i] === name); + }); + + localStorage.setItem('diora_active_radio_tab', name); + + if (name === 'saved') loadRecommendations(); +} + +// --------------------------------------------------------------------------- +// Podcasts +// --------------------------------------------------------------------------- + +function loadPodcastTab() { + loadFeedList().then(() => { + showPodcastView(podcastCurrentView); + }); +} + +function showPodcastView(view) { + podcastCurrentView = view; + const panes = ['search', 'feeds', 'inbox', 'episodes', 'queue']; + panes.forEach(p => { + const el = document.getElementById(`podcast-${p}-pane`); + if (el) el.style.display = (p === view) ? '' : 'none'; + }); + + if (view === 'feeds') renderFeedList(); + if (view === 'inbox') loadAndRenderInbox(); + if (view === 'queue') loadAndRenderQueue(); +} + +async function doPodcastSearch() { + const q = $('podcast-search-input').value.trim(); + if (!q) return; + const statusEl = $('podcast-search-status'); + const listEl = $('podcast-search-list'); + statusEl.textContent = 'Searching…'; + listEl.innerHTML = ''; + + try { + const res = await fetch('/podcasts/search/?q=' + encodeURIComponent(q)); + const data = await res.json(); + if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; } + const results = data.results || []; + statusEl.textContent = results.length ? `${results.length} result(s)` : 'No results.'; + + results.forEach(r => { + const div = document.createElement('div'); + div.className = 'podcast-search-item'; + div.innerHTML = ` + ${r.artwork_url ? `` : '
'} +
+
${escapeHtml(r.title)}
+
${escapeHtml(r.author)}
+
+ + `; + // Attach via addEventListener to avoid encoding strings in onclick attribute + div.querySelector('.podcast-subscribe-btn').addEventListener('click', () => { + subscribeFeed(r.rss_url, r.title); + }); + listEl.appendChild(div); + }); + } catch (e) { + statusEl.textContent = 'Search failed.'; } } +function podcastSearchOpen() { + showPodcastView('search'); +} + +function addFeedByUrl() { + const url = prompt('RSS feed URL:'); + if (url) subscribeFeed(url.trim(), ''); +} + +async function subscribeFeed(rssUrl, title) { + if (!rssUrl) return; + const statusEl = $('podcast-search-status') || $('opml-status'); + try { + const res = await fetch('/podcasts/feeds/add/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({rss_url: rssUrl, title: title || rssUrl}), + }); + const data = await res.json(); + if (data.ok) { + await loadFeedList(); + showPodcastView('feeds'); + } else if (statusEl) { + statusEl.textContent = 'Error: ' + (data.error || 'unknown'); + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Failed to subscribe.'; + } +} + +async function loadFeedList() { + try { + const res = await fetch('/podcasts/feeds/'); + const data = await res.json(); + podcastFeeds = data.feeds || []; + } catch (e) { + podcastFeeds = []; + } +} + +function renderFeedList() { + const container = $('podcast-feed-list'); + if (!container) return; + + if (!podcastFeeds.length) { + container.innerHTML = '

No subscriptions yet. Search or import OPML to add feeds.

'; + return; + } + + container.innerHTML = ''; + podcastFeeds.forEach(feed => { + const div = document.createElement('div'); + div.className = 'podcast-feed-item'; + div.innerHTML = ` + ${feed.artwork_url + ? `` + : '
'} +
+
${escapeHtml(feed.title)}
+ ${feed.author ? `
${escapeHtml(feed.author)}
` : ''} +
+
+ + + +
+ `; + container.appendChild(div); + }); +} + +async function openFeed(feedId) { + podcastCurrentFeedId = feedId; + showPodcastView('episodes'); + const headerEl = $('podcast-feed-header'); + const listEl = $('podcast-episode-list'); + if (headerEl) headerEl.innerHTML = '

Loading…

'; + if (listEl) listEl.innerHTML = ''; + + try { + const res = await fetch(`/podcasts/feeds/${feedId}/episodes/`); + const data = await res.json(); + const feed = data.feed; + const episodes = data.episodes || []; + + if (headerEl) { + headerEl.innerHTML = ` +
+ ${feed.artwork_url ? `` : ''} +
+
${escapeHtml(feed.title)}
+ ${feed.author ? `
${escapeHtml(feed.author)}
` : ''} +
+
+ `; + } + + renderEpisodeList(episodes, feedId, listEl); + } catch (e) { + if (headerEl) headerEl.innerHTML = '

Failed to load episodes.

'; + } +} + +function renderEpisodeList(episodes, feedId, container) { + if (!container) return; + if (!episodes.length) { + container.innerHTML = '

No episodes found.

'; + return; + } + + container.innerHTML = ''; + episodes.forEach(ep => { + // Cache episode data by id so onclick attrs only need the id (avoids encoding + // strings with quotes inside HTML attributes which breaks the attribute parser) + podcastEpCache[ep.id] = { + id: ep.id, + title: ep.title, + description: ep.description || '', + audioUrl: ep.audio_url, + durationSeconds: ep.duration_seconds, + positionSeconds: ep.position_seconds || 0, + feedId: feedId || 0, + played: ep.played, + }; + + const div = document.createElement('div'); + div.className = 'episode-item' + (ep.played ? ' episode-played' : ''); + div.id = `episode-item-${ep.id}`; + + const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : ''); + const dur = formatDuration(ep.duration_seconds); + const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : ''; + const posStr = ep.position_seconds > 0 ? ` · ${formatDuration(ep.position_seconds)} played` : ''; + + div.innerHTML = ` + ${artSrc ? `` : '
'} +
+
${escapeHtml(ep.title)}
+
${escapeHtml(dateStr)} · ${escapeHtml(dur)}${escapeHtml(posStr)}
+
+
+ + + + +
+ `; + container.appendChild(div); + }); +} + +function playEpisodeById(id) { + const ep = podcastEpCache[id]; + if (!ep) return; + playEpisode(ep.id, ep.title, ep.audioUrl, ep.durationSeconds, ep.positionSeconds, ep.feedId); +} + +function downloadEpisodeById(id, btn) { + const ep = podcastEpCache[id]; + if (!ep) return; + downloadEpisode(ep.audioUrl, ep.title, btn); +} + +function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) { + stopPlayback(false); + + podcastMode = true; + isPlaying = true; // fix: was missing, causing stop button to do nothing + currentEpisode = {id, title, audioUrl: url, durationSeconds, feedId}; + + audio.src = url; + const volSlider = $('volume'); + if (volSlider) audio.volume = volSlider.value / 255; + + if (positionSeconds > 0) { + audio.addEventListener('loadedmetadata', function onMeta() { + audio.currentTime = positionSeconds; + audio.removeEventListener('loadedmetadata', onMeta); + }); + } + + audio.play().catch(e => console.warn('Podcast play blocked:', e)); + + const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || 'Podcast'; + const stationEl = $('now-playing-station'); + stationEl.textContent = feedTitle; + stationEl.classList.add('podcast-station-link'); + stationEl.onclick = () => { showTab('podcasts'); openFeed(feedId); }; + + const trackEl = $('now-playing-track'); + trackEl.textContent = title; + trackEl.classList.add('podcast-track-link'); + trackEl.onclick = () => openEpisodeSidebar(id); + $('play-stop-btn').style.display = ''; + $('play-stop-btn').textContent = '⏹ Stop'; + $('play-stop-btn').classList.add('playing'); + + const seekBar = $('podcast-seek-bar'); + if (seekBar) seekBar.style.display = ''; + + const slider = $('seek-slider'); + if (slider && durationSeconds > 0) slider.max = durationSeconds; + + // Reset speed to 1× for each new episode + setPlaybackRate(1); + + audio.ontimeupdate = podcastTimeUpdate; + + // Media Session API — maps hardware media keys & lock-screen controls + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: title, + artist: (podcastFeeds.find(f => f.id === feedId) || {}).title || '', + }); + navigator.mediaSession.setActionHandler('seekbackward', () => skipBack()); + navigator.mediaSession.setActionHandler('seekforward', () => skipForward()); + navigator.mediaSession.setActionHandler('play', () => { audio.play(); }); + navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); }); + } + + if (seekSaveTimer) clearInterval(seekSaveTimer); + seekSaveTimer = setInterval(savePodcastProgress, 15000); +} + +function podcastTimeUpdate() { + const pos = Math.floor(audio.currentTime); + const dur = currentEpisode ? currentEpisode.durationSeconds : 0; + + const curEl = $('seek-current'); + if (curEl) curEl.textContent = formatDuration(pos); + + const durEl = $('seek-duration'); + if (durEl) durEl.textContent = formatDuration(dur || Math.floor(audio.duration) || 0); + + const slider = $('seek-slider'); + if (slider) { + if (dur > 0) { + slider.max = dur; + } else if (audio.duration && isFinite(audio.duration)) { + slider.max = Math.floor(audio.duration); + } + slider.value = pos; + } +} + +function skipBack() { + audio.currentTime = Math.max(0, audio.currentTime - 15); +} + +function skipForward() { + const dur = audio.duration; + audio.currentTime = dur && isFinite(dur) + ? Math.min(dur, audio.currentTime + 30) + : audio.currentTime + 30; +} + +function setPlaybackRate(rate) { + audio.playbackRate = rate; + // Keep pitch natural at non-1× speeds (supported in all modern browsers) + audio.preservesPitch = true; + + document.querySelectorAll('.speed-btn').forEach(btn => { + btn.classList.toggle('active', parseFloat(btn.textContent) === rate + || (rate === 0.75 && btn.textContent.startsWith('¾')) + || (rate === 1.25 && btn.textContent.startsWith('1¼')) + || (rate === 1.5 && btn.textContent.startsWith('1½'))); + }); +} + +async function savePodcastProgress() { + if (!currentEpisode) return; + const pos = Math.floor(audio.currentTime); + try { + await fetch('/podcasts/progress/save/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: currentEpisode.id, position_seconds: pos}), + }); + } catch (e) {} +} + +async function loadAndRenderInbox() { + const listEl = $('podcast-inbox-list'); + if (!listEl) return; + listEl.innerHTML = '

Loading…

'; + + try { + const res = await fetch('/podcasts/inbox/'); + const data = await res.json(); + const episodes = data.episodes || []; + + listEl.innerHTML = ''; + if (!episodes.length) { + listEl.innerHTML = '

Inbox empty — all caught up!

'; + return; + } + + episodes.forEach(ep => { + podcastEpCache[ep.id] = { + id: ep.id, + title: ep.title, + audioUrl: ep.audio_url, + durationSeconds: ep.duration_seconds, + positionSeconds: 0, + feedId: ep['feed__id'], + played: false, + }; + + const div = document.createElement('div'); + div.className = 'episode-item'; + div.innerHTML = ` + ${ep['feed__artwork_url'] ? `` : '
'} +
+
${escapeHtml(ep.title)}
+
${escapeHtml(ep['feed__title'])} · ${ep.pub_date ? ep.pub_date.slice(0,10) : ''} · ${formatDuration(ep.duration_seconds)}
+
+
+ + + +
+ `; + listEl.appendChild(div); + }); + } catch (e) { + listEl.innerHTML = '

Failed to load inbox.

'; + } +} + +async function loadAndRenderQueue() { + const ol = $('podcast-queue-ol'); + if (!ol) return; + ol.innerHTML = '
  • Loading…
  • '; + + try { + const res = await fetch('/podcasts/queue/'); + const data = await res.json(); + const items = data.queue || []; + podcastQueue = items; + + ol.innerHTML = ''; + if (!items.length) { + ol.innerHTML = '
  • Queue is empty.
  • '; + return; + } + + items.forEach(item => { + const epId = item['episode__id']; + podcastEpCache[epId] = { + id: epId, + title: item['episode__title'], + audioUrl: item['episode__audio_url'], + durationSeconds: item['episode__duration_seconds'], + positionSeconds: 0, + feedId: item['episode__feed__id'], + played: false, + }; + + const li = document.createElement('li'); + li.className = 'episode-item'; + li.innerHTML = ` +
    +
    ${escapeHtml(item['episode__title'])}
    +
    ${escapeHtml(item['episode__feed__title'])} · ${formatDuration(item['episode__duration_seconds'])}
    +
    +
    + + + +
    + `; + ol.appendChild(li); + }); + } catch (e) { + ol.innerHTML = '
  • Failed to load queue.
  • '; + } +} + +async function queueAddEpisode(id) { + try { + await fetch('/podcasts/queue/add/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: id}), + }); + } catch (e) {} +} + +async function queueRemoveEpisode(id) { + try { + await fetch('/podcasts/queue/remove/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: id}), + }); + if (podcastCurrentView === 'queue') loadAndRenderQueue(); + } catch (e) {} +} + +async function toggleMarkPlayed(id, btn) { + const ep = podcastEpCache[id]; + const current = ep ? ep.played : btn.textContent === '✓'; + const newPlayed = !current; + try { + const res = await fetch('/podcasts/progress/mark-played/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({episode_id: id, played: newPlayed}), + }); + if (res.ok) { + if (ep) ep.played = newPlayed; + btn.textContent = newPlayed ? '✓' : '○'; + const item = document.getElementById(`episode-item-${id}`); + if (item) item.classList.toggle('episode-played', newPlayed); + } + } catch (e) {} +} + +async function refreshFeed(feedId) { + try { + const res = await fetch('/podcasts/feeds/refresh/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({feed_id: feedId}), + }); + const data = await res.json(); + if (data.ok && podcastCurrentFeedId === feedId) { + openFeed(feedId); + } + } catch (e) {} +} + +async function removeFeed(feedId) { + if (!confirm('Remove this podcast?')) return; + try { + await fetch(`/podcasts/feeds/${feedId}/remove/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }); + podcastFeeds = podcastFeeds.filter(f => f.id !== feedId); + renderFeedList(); + } catch (e) {} +} + +async function importOPML(input) { + const file = input.files[0]; + if (!file) return; + const statusEl = $('opml-status'); + if (statusEl) statusEl.textContent = 'Importing…'; + + const form = new FormData(); + form.append('file', file); + form.append('csrfmiddlewaretoken', getCsrfToken()); + + try { + const res = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form}); + const data = await res.json(); + if (data.ok) { + if (statusEl) statusEl.textContent = `✓ ${data.added} added, ${data.skipped} already subscribed`; + await loadFeedList(); + renderFeedList(); + } else { + if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'unknown'); + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Import failed.'; + } + input.value = ''; +} + +async function downloadEpisode(url, title, btn) { + if (!('caches' in window)) { + alert('Cache API not supported in this browser.'); + return; + } + + const origText = btn ? btn.textContent : '⬇'; + if (btn) { btn.textContent = '…'; btn.disabled = true; } + + try { + const cache = await caches.open('diora-podcast-v1'); + const existing = await cache.match(url); + if (existing) { + if (btn) { btn.textContent = '✓'; btn.disabled = false; } + return; + } + // Use no-cors so cross-origin audio URLs don't block the fetch + const resp = await fetch(url, {mode: 'no-cors'}); + await cache.put(url, resp); + if (btn) { btn.textContent = '✓'; btn.disabled = false; } + } catch (e) { + if (btn) { btn.textContent = origText; btn.disabled = false; } + alert('Download failed: ' + e.message); + } +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +function openSidebar(title, htmlContent) { + $('sidebar-title').textContent = title; + $('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent); + $('sidebar-overlay').style.display = ''; + $('sidebar').classList.add('open'); +} + +function closeSidebar() { + $('sidebar').classList.remove('open'); + $('sidebar-overlay').style.display = 'none'; +} + +document.addEventListener('keydown', e => { + const overlay = $('reader-overlay'); + const readerOpen = overlay && overlay.style.display !== 'none'; + + if (e.key === 'Escape') { + if (readerOpen) { + closeReader(); + } else { + closeSidebar(); + } + } + + if (readerOpen) { + if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') { + e.preventDefault(); + toggleReaderSearch(); + } + if (readerSearchOpen) { + if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } + if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } + } + if (readerSettings.pdfPaginated && currentPdfDoc) { + if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } + if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } + } + } +}); + +function sanitizeSidebarHtml(html) { + if (!html) return '

    No show notes available.

    '; + const div = document.createElement('div'); + div.innerHTML = html; + div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove()); + div.querySelectorAll('*').forEach(el => { + Array.from(el.attributes).forEach(attr => { + if (attr.name.startsWith('on')) el.removeAttribute(attr.name); + }); + if (el.tagName === 'A') { + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener noreferrer'); + } + }); + return div.innerHTML; +} + +function openEpisodeSidebar(id) { + const ep = podcastEpCache[id]; + if (!ep) return; + openSidebar(ep.title, ep.description || ''); +} + +function formatDuration(seconds) { + if (!seconds || seconds <= 0) return '0:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + } + return `${m}:${String(s).padStart(2, '0')}`; +} + // --------------------------------------------------------------------------- // Service Worker // --------------------------------------------------------------------------- @@ -1076,17 +1781,2073 @@ function toggleContrast() { setScheme(!document.body.classList.contains('bright-bg')); } +// --------------------------------------------------------------------------- +// E2E Encryption utilities (Web Crypto API) +// --------------------------------------------------------------------------- + +let _encKey = null; + +function bytesToBase64(buf) { + const bytes = new Uint8Array(buf); + let str = ''; + for (const b of bytes) str += String.fromCharCode(b); + return btoa(str); +} + +function base64ToBytes(b64) { + const str = atob(b64); + const buf = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); + return buf; +} + +function bytesToHex(buf) { + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex) { + const arr = new Uint8Array(hex.length / 2); + for (let i = 0; i < arr.length; i++) arr[i] = parseInt(hex.slice(i*2, i*2+2), 16); + return arr; +} + +async function getOrCreateEncKey() { + if (_encKey) return _encKey; + const storageKey = `diora_enc_key_${typeof USER_ID !== 'undefined' ? USER_ID : 'anon'}`; + const stored = localStorage.getItem(storageKey); + if (stored) { + try { + const raw = base64ToBytes(stored); + _encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); + return _encKey; + } catch (e) { /* fall through */ } + } + throw new Error('No encryption key found. Please log out and log in again to unlock encrypted content.'); +} + +async function encryptBytes(key, plainBytes) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes); + return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)}; +} + +async function decryptBytes(key, ivHex, ctB64) { + const iv = hexToBytes(ivHex); + const ct = base64ToBytes(ctB64); + return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct); +} + +// --------------------------------------------------------------------------- +// Encrypted wallpaper +// --------------------------------------------------------------------------- + +async function uploadBackground(file) { + if (!file) return; + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + alert('Only JPEG, PNG, or WebP images are allowed.'); + return; + } + if (file.size > 5 * 1024 * 1024) { + alert('Image must be 5 MB or smaller.'); + return; + } + + const key = await getOrCreateEncKey(); + const buf = await file.arrayBuffer(); + const {iv, ciphertext} = await encryptBytes(key, buf); + + const res = await fetch('/accounts/background/upload/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}), + }); + const data = await res.json(); + if (!data.ok) throw new Error(data.error || 'upload failed'); +} + +async function applyEncryptedBackground() { + if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return; + try { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext); + const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'}); + const url = URL.createObjectURL(blob); + document.body.style.backgroundImage = `url('${url}')`; + document.body.style.backgroundSize = 'cover'; + document.body.style.backgroundPosition = 'center'; + document.body.style.backgroundAttachment = 'fixed'; + + // Analyze brightness for accent/scheme + analyzeBackground(url).then(({bright, bgLuminance}) => { + setScheme(bright); + applyAccent(pickBestAccent(bgLuminance)); + }); + } catch (e) { + console.warn('Could not decrypt background:', e); + } +} + +// --------------------------------------------------------------------------- +// EPUB parser (requires JSZip) +// --------------------------------------------------------------------------- + +function resolveEpubPath(base, relative) { + if (!relative) return ''; + if (relative.startsWith('/')) return relative.slice(1); + const hashIdx = relative.indexOf('#'); + const frag = hashIdx >= 0 ? relative.slice(hashIdx) : ''; + const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative; + const parts = (base + rel).split('/'); + const resolved = []; + for (const p of parts) { + if (p === '..') resolved.pop(); + else if (p !== '.') resolved.push(p); + } + return resolved.join('/') + frag; +} + +async function parseEpub(arrayBuffer) { + const zip = await JSZip.loadAsync(arrayBuffer); + + // 1. Find OPF via container.xml + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); + const rootfileEl = containerDoc.querySelector('rootfile'); + if (!rootfileEl) throw new Error('No rootfile in container.xml'); + const opfPath = rootfileEl.getAttribute('full-path'); + const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; + + // 2. Parse OPF + const opfText = await zip.file(opfPath).async('text'); + const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); + + const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unknown Title'; + const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author'; + + // 3. Build manifest: id → {href, mediaType, properties} + const manifest = {}; + opfDoc.querySelectorAll('manifest > item').forEach(item => { + manifest[item.getAttribute('id')] = { + href: opfDir + item.getAttribute('href'), + mediaType: item.getAttribute('media-type') || '', + properties: item.getAttribute('properties') || '', + }; + }); + + // 4. Build image map: abs zip path → blob URL + const imageMap = {}; + for (const {href, mediaType} of Object.values(manifest)) { + if (mediaType.startsWith('image/')) { + try { + const buf = await zip.file(href).async('arraybuffer'); + imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType})); + } catch (e) { /* missing asset */ } + } + } + + // 5. Parse TOC + const toc = await _parseEpubToc(zip, opfDoc, manifest); + + // 6. Get spine and concatenate chapters + const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref')) + .map(ref => manifest[ref.getAttribute('idref')]?.href) + .filter(Boolean); + + const parts = []; + for (let i = 0; i < spineItems.length; i++) { + const href = spineItems[i]; + try { + const chapterText = await zip.file(href).async('text'); + const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : ''; + const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); + const sanitized = sanitizeEpubHtml(withBlobs); + parts.push(`
    ${sanitized}
    `); + } catch (e) { /* skip missing */ } + } + + return {title, author, html: parts.join('\n'), toc, imageMap}; +} + +async function _parseEpubToc(zip, opfDoc, manifest) { + // Try EPUB3 nav document + const navItem = Object.values(manifest).find(m => m.properties.includes('nav')); + if (navItem) { + try { + const navText = await zip.file(navItem.href).async('text'); + const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml'); + const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav'); + if (tocNav) { + const ol = tocNav.querySelector('ol'); + if (ol) return _parseTocOl(ol, navItem.href, 0); + } + } catch (e) {} + } + + // Fall back to EPUB2 NCX + const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml'); + if (ncxItem) { + try { + const ncxText = await zip.file(ncxItem.href).async('text'); + const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml'); + const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : ''; + return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0); + } catch (e) {} + } + + return []; +} + +function _parseTocOl(ol, navHref, depth) { + if (!ol || depth > 5) return []; + const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : ''; + const items = []; + for (const li of Array.from(ol.children)) { + const a = li.querySelector(':scope > a') || li.querySelector(':scope > span'); + if (a) { + const rawHref = a.getAttribute('href') || ''; + items.push({ + label: a.textContent.trim(), + href: rawHref ? resolveEpubPath(navDir, rawHref) : '', + depth, + }); + } + const childOl = li.querySelector(':scope > ol'); + if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1)); + } + return items; +} + +function _parseNcxNavMap(navMap, ncxDir, depth) { + if (!navMap || depth > 5) return []; + const items = []; + for (const navPoint of Array.from(navMap.children)) { + if (navPoint.tagName !== 'navPoint') continue; + const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || ''; + const src = navPoint.querySelector('content')?.getAttribute('src') || ''; + items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth}); + items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1)); + } + return items; +} + +function _resolveImageBlob(imageMap, absPath) { + if (imageMap[absPath]) return imageMap[absPath]; + // Fallback: match by decoded filename only + const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase(); + for (const [k, v] of Object.entries(imageMap)) { + if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v; + } + return null; +} + +// Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it. +// This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes. +function _injectImageBlobs(html, chapterDir, imageMap) { + function subst(src) { + if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src; + return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || ''; + } + // + html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); + // SVG + html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); + // SVG + html = html.replace(/(]*?)\bhref="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); + return html; +} + +function sanitizeEpubHtml(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + + doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove()); + + doc.querySelectorAll('*').forEach(el => { + Array.from(el.attributes).forEach(attr => { + if (attr.name.startsWith('on')) el.removeAttribute(attr.name); + }); + const tag = el.tagName.toLowerCase(); + if (tag === 'img') { + const src = el.getAttribute('src') || ''; + // Remove any non-blob, non-data src that slipped through (broken relative paths) + if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src'); + } + if (el.tagName === 'A') { + const href = el.getAttribute('href') || ''; + if (href.startsWith('http://') || href.startsWith('https://')) { + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener noreferrer'); + } else { + el.removeAttribute('href'); + el.style.cursor = 'default'; + } + } + }); + + return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; +} + +// --------------------------------------------------------------------------- +// Books +// --------------------------------------------------------------------------- + +let currentBookId = null; +let currentBookToc = []; +let currentImageMap = {}; +let readerScrollSaveTimer = null; +const bookMetaCache = {}; // id → {title, author, type} + +// Reader settings +let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', + pdfZoom: 100, pdfInverted: false, pdfPaginated: false }; +let readerSettingsPanelOpen = false; +let currentPdfDoc = null; +let currentPdfBuffer = null; + +// Bookmarks +let currentBookmarks = []; +let bookmarksDirty = false; + +// Highlights +let currentHighlights = []; +let highlightsDirty = false; +let currentHighlightPopover = null; + +// Search +let searchMatches = []; +let searchMatchIndex = -1; +let searchOriginalContent = null; +let readerSearchOpen = false; + +// PDF paginated +let pdfCurrentPage = 1; +let pdfTotalPages = 0; +let _pdfPageTextBoxCache = {}; +let _touchStartX = 0; + +if (typeof pdfjsLib !== 'undefined') { + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; +} +const DEFAULT_FOCUS_STATION = { + url: 'https://ice5.somafm.com/groovesalad-128-aac', + name: 'SomaFM Groove Salad', +}; + +async function loadBookList() { + if (!IS_AUTHENTICATED) return; + const listEl = $('book-list'); + if (!listEl) return; + listEl.innerHTML = '

    Loading…

    '; + + try { + const res = await fetch('/books/'); + const books = await res.json(); + if (!books.length) { + listEl.innerHTML = '

    No books yet. Drop an .epub or .pdf above.

    '; + return; + } + + const key = await getOrCreateEncKey(); + const decrypted = []; + for (const b of books) { + try { + const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); + const meta = JSON.parse(new TextDecoder().decode(metaBuf)); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at}); + } catch (e) { + bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; + decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at}); + } + } + renderBookList(decrypted); + } catch (e) { + if (listEl) listEl.innerHTML = '

    Failed to load books.

    '; + } +} + +function renderBookList(books) { + const listEl = $('book-list'); + if (!listEl) return; + let html = ''; + for (const b of books) { + const pct = Math.round((b.scroll_fraction || 0) * 100); + html += `
    +
    + ${escapeHtml(b.title)} + ${escapeHtml(b.author)} + ${pct > 0 ? `${pct}% read` : ''} +
    +
    + + +
    +
    `; + } + listEl.innerHTML = html; +} + +function bookFileSelected(input) { + const file = input.files[0]; + if (!file) return; + uploadEbook(file); + input.value = ''; +} + +function initBookDropZone() { + const zone = $('book-drop-zone'); + if (!zone) return; + + zone.addEventListener('dragover', e => { + e.preventDefault(); + zone.classList.add('drag-over'); + }); + zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); + zone.addEventListener('drop', e => { + e.preventDefault(); + zone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) uploadEbook(file); + }); +} + +async function uploadEbook(file) { + const statusEl = $('book-upload-status'); + const isPdf = /\.pdf$/i.test(file.name); + const isEpub = /\.epub$/i.test(file.name); + if (!isPdf && !isEpub) { + if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.'; + return; + } + if (file.size > 10 * 1024 * 1024) { + if (statusEl) statusEl.textContent = 'File too large (max 10 MB).'; + return; + } + + if (statusEl) statusEl.textContent = 'Encrypting…'; + + try { + const buf = await file.arrayBuffer(); + + let title = file.name.replace(/\.(epub|pdf)$/i, ''); + let author = ''; + const type = isPdf ? 'pdf' : 'epub'; + + if (isPdf) { + try { + const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; + const meta = await pdfDoc.getMetadata(); + title = meta.info?.Title?.trim() || title; + author = meta.info?.Author?.trim() || ''; + } catch (e) { /* use filename as title */ } + } else { + try { + const zip = await JSZip.loadAsync(buf.slice(0)); + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); + const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path'); + if (opfPath) { + const opfText = await zip.file(opfPath).async('text'); + const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); + title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; + author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; + } + } catch (e) { /* use filename as title */ } + } + + const key = await getOrCreateEncKey(); + const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type})); + const [metaEnc, dataEnc] = await Promise.all([ + encryptBytes(key, metaJson), + encryptBytes(key, buf), + ]); + + if (statusEl) statusEl.textContent = 'Uploading…'; + + const res = await fetch('/books/upload/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ + meta_ct: metaEnc.ciphertext, + meta_iv: metaEnc.iv, + data_ct: dataEnc.ciphertext, + data_iv: dataEnc.iv, + }), + }); + const data = await res.json(); + if (data.ok) { + if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`; + loadBookList(); + } else { + if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed'); + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message; + } +} + +async function _parsePdfOutline(pdf, items, depth) { + depth = depth || 0; + const result = []; + for (const item of items) { + let href = ''; + if (item.dest) { + try { + const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; + if (dest) { + const pageIndex = await pdf.getPageIndex(dest[0]); + href = `#pdf-page-${pageIndex + 1}`; + } + } catch (e) {} + } + result.push({label: item.title || '(untitled)', href, depth}); + if (item.items && item.items.length) { + result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); + } + } + return result; +} + +async function renderPdf(arrayBuffer, contentEl, scaleOverride) { + const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer)}).promise; + currentPdfDoc = pdf; + + let pdfTitle = '', pdfAuthor = ''; + try { + const meta = await pdf.getMetadata(); + pdfTitle = meta.info?.Title?.trim() || ''; + pdfAuthor = meta.info?.Author?.trim() || ''; + } catch (e) {} + + let toc = []; + try { + const outline = await pdf.getOutline(); + if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); + } catch (e) {} + + contentEl.innerHTML = ''; + + const containerWidth = contentEl.clientWidth - 32; + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + const page = await pdf.getPage(pageNum); + const naturalVp = page.getViewport({scale: 1}); + const scale = scaleOverride != null ? scaleOverride + : Math.max(0.5, (containerWidth / naturalVp.width) * (readerSettings.pdfZoom / 100)); + const viewport = page.getViewport({scale}); + + const wrapper = document.createElement('div'); + wrapper.className = 'pdf-page-wrapper'; + wrapper.id = `pdf-page-${pageNum}`; + + // Inner container gives canvas + text layer a shared position:relative origin, + // independent of the outer flex wrapper's centering. + const inner = document.createElement('div'); + inner.className = 'pdf-page-inner'; + + const canvas = document.createElement('canvas'); + canvas.className = 'pdf-page'; + canvas.width = viewport.width; + canvas.height = viewport.height; + inner.appendChild(canvas); + wrapper.appendChild(inner); + contentEl.appendChild(wrapper); + + await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; + + // Text layer disabled — re-enable once overlay rendering is resolved + } + + pdfTotalPages = pdf.numPages; + return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; +} + +async function openBook(bookId) { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + const titleEl = $('reader-title'); + if (!overlay || !contentEl) return; + + titleEl.textContent = 'Loading…'; + contentEl.innerHTML = ''; + overlay.style.display = ''; + + try { + loadReaderSettings(); + const key = await getOrCreateEncKey(); + const res = await fetch(`/books/${bookId}/data/`); + const {data_ct, data_iv} = await res.json(); + const plain = await decryptBytes(key, data_iv, data_ct); + + // Revoke any previous image blob URLs + for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); + currentImageMap = {}; + + const cachedMeta = bookMetaCache[bookId] || {}; + let title = cachedMeta.title || ''; + let author = cachedMeta.author || ''; + let toc = []; + let numPages = 0; + const isPdfBook = cachedMeta.type === 'pdf'; + + if (isPdfBook) { + currentPdfDoc = null; // reset so renderPdf creates fresh doc + const result = await renderPdf(plain, contentEl); + title = result.title || title; + author = result.author || author; + toc = result.toc; + numPages = result.numPages; + currentPdfBuffer = plain; + } else { + currentPdfBuffer = null; + const result = await parseEpub(plain); + title = result.title || title; + author = result.author || author; + toc = result.toc; + currentImageMap = result.imageMap; + contentEl.innerHTML = result.html; + } + + currentBookToc = toc; + titleEl.textContent = title + (author ? ` — ${author}` : ''); + + currentBookId = bookId; + + // Load bookmarks and highlights + await Promise.all([ + loadBookmarks(bookId), + loadHighlights(bookId), + ]); + + // Apply reader settings (theme, font size, etc.) + applyReaderSettings(isPdfBook); + + // Enable PDF paginated mode if configured (auto on mobile) + if (isPdfBook && readerSettings.pdfPaginated) { + enterPdfPaginatedMode(); + } + + // Wire highlight selection listener for EPUB + if (!isPdfBook) { + contentEl.addEventListener('mouseup', handleReaderSelection); + } + + // Swipe for PDF paginated + contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true}); + contentEl.addEventListener('touchend', e => { + if (!readerSettings.pdfPaginated) return; + const delta = e.changedTouches[0].clientX - _touchStartX; + if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); + else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); + }, {passive: true}); + + // Set up progress input + const progressInput = $('reader-progress-input'); + const progressSuffix = $('reader-progress-suffix'); + const isPdf = isPdfBook; + + if (progressInput) { + progressInput.style.display = ''; + if (isPdf) { + progressInput.min = 1; + progressInput.max = numPages; + progressInput.value = 1; + if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`; + } else { + progressInput.min = 0; + progressInput.max = 100; + progressInput.value = 0; + if (progressSuffix) progressSuffix.textContent = '%'; + } + + progressInput.addEventListener('change', function () { + if (isPdf) { + const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); + this.value = page; + const target = contentEl.querySelector(`#pdf-page-${page}`); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); + } + } else { + const pct = Math.min(100, Math.max(0, parseInt(this.value, 10) || 0)); + this.value = pct; + contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight; + } + }); + + progressInput.addEventListener('click', function () { this.select(); }); + } + + // Restore scroll position + try { + const progressRes = await fetch('/books/'); + const allBooks = await progressRes.json(); + const bookData = allBooks.find(b => b.id === bookId); + const fraction = bookData ? (bookData.scroll_fraction || 0) : 0; + if (fraction > 0) { + if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { + pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + } else { + // For EPUB: wait for all images to load so scrollHeight is final + if (!isPdf) { + const imgs = Array.from(contentEl.querySelectorAll('img')); + if (imgs.length) { + await Promise.all(imgs.map(img => + img.complete ? Promise.resolve() + : new Promise(r => { img.onload = r; img.onerror = r; }) + )); + } + } + // One more rAF to let the browser recalculate layout after image load + await new Promise(r => requestAnimationFrame(r)); + contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); + } + } + } catch (e) {} + + // Update progress input on scroll + contentEl.addEventListener('scroll', () => { + if (!progressInput) return; + if (isPdf) { + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + const cTop = contentEl.getBoundingClientRect().top; + let currentPage = 1; + for (const w of wrappers) { + if (w.getBoundingClientRect().bottom > cTop + 20) { + currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; + break; + } + } + progressInput.value = currentPage; + } else { + const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); + progressInput.value = Math.round(f * 100); + } + }); + + // Auto-save progress every 10s and on scroll (debounced 2s) + readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); + let _scrollDebounce = null; + contentEl.addEventListener('scroll', () => { + clearTimeout(_scrollDebounce); + _scrollDebounce = setTimeout(saveReaderProgress, 2000); + }, {passive: true}); + + // Determine which station to play (null = use default, {url:''} = disabled) + const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION + : (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null); + + if (focusStation) { + if (isPlaying) { + // Don't interrupt — highlight button, play on click instead + const btn = $('focus-station-btn'); + if (btn) { + btn.classList.add('focus-pending'); + btn.title = `Click to play focus station: ${focusStation.name}`; + btn._pendingFocusStation = focusStation; + btn.onclick = function () { + playStation(focusStation.url, focusStation.name, null); + btn.classList.remove('focus-pending'); + btn.title = 'Focus station'; + btn._pendingFocusStation = null; + btn.onclick = openFocusStationSidebar; + }; + } + } else { + playStation(focusStation.url, focusStation.name, null); + } + } + + } catch (e) { + contentEl.innerHTML = `

    Failed to open book: ${escapeHtml(e.message)}

    `; + } +} + +async function exportEncKey() { + const statusEl = $('book-key-status'); + try { + const key = await getOrCreateEncKey(); + const raw = await crypto.subtle.exportKey('raw', key); + const b64 = bytesToBase64(raw); + await navigator.clipboard.writeText(b64); + if (statusEl) statusEl.textContent = '✓ Key copied to clipboard'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000); + } catch (e) { + if (statusEl) statusEl.textContent = 'Export failed: ' + e.message; + } +} + +function showImportKey() { + const body = $('sidebar-body'); + openSidebar('Import encryption key', ` +

    Paste the key exported from your other browser:

    + + +

    This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.

    + `); + body.addEventListener('click', async function _importClick(e) { + if (!e.target.closest('[data-import-key-apply]')) return; + body.removeEventListener('click', _importClick); + const b64 = (body.querySelector('#import-key-input')?.value || '').trim(); + const statusEl = $('book-key-status'); + try { + const raw = base64ToBytes(b64); + const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); + const re_exported = await crypto.subtle.exportKey('raw', importedKey); + localStorage.setItem(`diora_enc_key_${USER_ID}`, bytesToBase64(re_exported)); + closeSidebar(); + if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…'; + await loadBookList(); + if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000); + } catch (e) { + if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key'; + } + }); +} + +async function deleteBook(bookId) { + if (!confirm('Delete this book? This cannot be undone.')) return; + try { + const res = await fetch(`/books/${bookId}/delete/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }); + const data = await res.json(); + if (data.ok) loadBookList(); + } catch (e) {} +} + +async function saveReaderProgress() { + if (!currentBookId) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + + let fraction; + if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) { + fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1); + } else { + fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); + } + fraction = Math.min(1.0, Math.max(0.0, fraction)); + + // Cache for sendBeacon on unload + _lastProgressBeacon = { + url: `/books/${currentBookId}/progress/`, + body: JSON.stringify({scroll_fraction: fraction}), + }; + + try { + await fetch(`/books/${currentBookId}/progress/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: _lastProgressBeacon.body, + }); + } catch (e) {} +} + +function closeReader() { + // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none + saveReaderProgress(); + if (bookmarksDirty) saveBookmarks(); + if (highlightsDirty) saveHighlights(); + + const overlay = $('reader-overlay'); + if (overlay) overlay.style.display = 'none'; + if (readerScrollSaveTimer) { + clearInterval(readerScrollSaveTimer); + readerScrollSaveTimer = null; + } + + // Clear search before wiping content + clearReaderSearch(); + + // Close settings panel if open + readerSettingsPanelOpen = false; + const sp = document.getElementById('reader-settings-panel'); + if (sp) sp.remove(); + + // Reset progress input + const progressInput = $('reader-progress-input'); + if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; } + const progressSuffix = $('reader-progress-suffix'); + if (progressSuffix) progressSuffix.textContent = ''; + + // Free image blob URLs + const contentEl = $('reader-content'); + if (contentEl) contentEl.innerHTML = ''; + for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); + currentImageMap = {}; + + // Reset all state + currentBookId = null; + currentBookToc = []; + currentPdfDoc = null; + currentPdfBuffer = null; + currentBookmarks = []; + bookmarksDirty = false; + currentHighlights = []; + highlightsDirty = false; + _lastProgressBeacon = null; + _lastBookmarkBeacon = null; + _lastHighlightBeacon = null; + dismissHighlightPopover(); + pdfCurrentPage = 1; + pdfTotalPages = 0; + _pdfPageTextBoxCache = {}; + + // Remove PDF invert class + if (overlay) overlay.classList.remove('pdf-inverted'); + + // Clear any pending focus station highlight + const btn = $('focus-station-btn'); + if (btn && btn._pendingFocusStation) { + btn.classList.remove('focus-pending'); + btn.title = 'Focus station'; + btn._pendingFocusStation = null; + btn.onclick = openFocusStationSidebar; + } +} + +function openTocSidebar() { + if (!currentBookToc.length) { + openSidebar('Table of Contents', '

    No table of contents found in this book.

    '); + return; + } + let html = '
      '; + for (const entry of currentBookToc) { + const indent = entry.depth * 14; + // Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml + html += `
    • + +
    • `; + } + html += '
    '; + openSidebar('Table of Contents', html); + // Attach delegated listener after sidebar body is populated + const body = $('sidebar-body'); + body.addEventListener('click', function _tocClick(e) { + const btn = e.target.closest('.toc-entry'); + if (btn) { + body.removeEventListener('click', _tocClick); + jumpToTocEntry(btn.getAttribute('data-toc-href') || ''); + } + }); +} + +function jumpToTocEntry(href) { + closeSidebar(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + + // PDF page jump + if (href.startsWith('#pdf-page-')) { + const target = contentEl.querySelector(href); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + return; + } + + const hashIdx = href.indexOf('#'); + const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''; + const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href; + + let target = null; + if (fragment) { + target = contentEl.querySelector(`#${CSS.escape(fragment)}`); + } + if (!target && filePath) { + target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) + .find(el => el.getAttribute('data-epub-src') === filePath) || null; + } + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + }, 50); +} + +// --------------------------------------------------------------------------- +// Reader Settings +// --------------------------------------------------------------------------- + +function loadReaderSettings() { + try { + const saved = JSON.parse(localStorage.getItem('diora_reader_settings') || '{}'); + Object.assign(readerSettings, saved); + // Auto-paginate on mobile if not explicitly set + if (saved.pdfPaginated === undefined) { + readerSettings.pdfPaginated = window.innerWidth < 768; + } + } catch (e) {} +} + +function saveReaderSettings() { + localStorage.setItem('diora_reader_settings', JSON.stringify(readerSettings)); +} + +function applyReaderSettings(isPdf) { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + if (!overlay || !contentEl) return; + + if (!isPdf) { + contentEl.style.fontSize = readerSettings.fontSize + 'px'; + contentEl.style.lineHeight = readerSettings.lineHeight; + contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); + } + + // Theme + overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright'); + if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia'); + else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright'); + + // PDF invert + if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); + else overlay.classList.remove('pdf-inverted'); +} + +function toggleSettingsPanel() { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + if (!overlay || !contentEl) return; + + const existing = document.getElementById('reader-settings-panel'); + if (existing) { + existing.remove(); + readerSettingsPanelOpen = false; + return; + } + + readerSettingsPanelOpen = true; + const isPdf = !!currentPdfDoc; + + const panel = document.createElement('div'); + panel.id = 'reader-settings-panel'; + panel.className = 'reader-settings-panel'; + + if (!isPdf) { + panel.innerHTML = ` + + + + + + + + `; + } else { + panel.innerHTML = ` + + + + `; + } + + overlay.insertBefore(panel, contentEl); + + if (!isPdf) { + const fontRange = panel.querySelector('#rs-font'); + const fontVal = panel.querySelector('#rs-font-val'); + fontRange.addEventListener('input', () => { + readerSettings.fontSize = parseInt(fontRange.value, 10); + fontVal.textContent = readerSettings.fontSize + 'px'; + applyReaderSettings(false); + saveReaderSettings(); + }); + + const lineRange = panel.querySelector('#rs-line'); + const lineVal = panel.querySelector('#rs-line-val'); + lineRange.addEventListener('input', () => { + readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1); + lineVal.textContent = readerSettings.lineHeight; + applyReaderSettings(false); + saveReaderSettings(); + }); + + const widthRange = panel.querySelector('#rs-width'); + const widthVal = panel.querySelector('#rs-width-val'); + widthRange.addEventListener('input', () => { + readerSettings.maxWidth = parseInt(widthRange.value, 10); + widthVal.textContent = readerSettings.maxWidth + 'ch'; + applyReaderSettings(false); + saveReaderSettings(); + }); + + panel.querySelector('#rs-width-full').addEventListener('click', () => { + readerSettings.maxWidth = 999; + widthRange.value = 90; + widthVal.textContent = 'full'; + applyReaderSettings(false); + saveReaderSettings(); + }); + + panel.querySelectorAll('[data-rs-theme]').forEach(btn => { + btn.addEventListener('click', () => { + readerSettings.theme = btn.dataset.rsTheme; + panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn)); + applyReaderSettings(false); + saveReaderSettings(); + }); + }); + } else { + const zoomRange = panel.querySelector('#rs-zoom'); + const zoomVal = panel.querySelector('#rs-zoom-val'); + zoomRange.addEventListener('change', () => { + readerSettings.pdfZoom = parseInt(zoomRange.value, 10); + zoomVal.textContent = readerSettings.pdfZoom + '%'; + saveReaderSettings(); + reRenderPdf(); + }); + + panel.querySelector('#rs-invert').addEventListener('click', function () { + readerSettings.pdfInverted = !readerSettings.pdfInverted; + this.classList.toggle('active', readerSettings.pdfInverted); + applyReaderSettings(true); + saveReaderSettings(); + }); + + panel.querySelector('#rs-paginated').addEventListener('click', function () { + readerSettings.pdfPaginated = !readerSettings.pdfPaginated; + this.classList.toggle('active', readerSettings.pdfPaginated); + saveReaderSettings(); + if (readerSettings.pdfPaginated) { + enterPdfPaginatedMode(); + } else { + exitPdfPaginatedMode(); + } + }); + } +} + +async function reRenderPdf() { + if (!currentPdfBuffer) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + currentPdfDoc = null; // force re-parse with same buffer + await renderPdf(currentPdfBuffer, contentEl); + if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); +} + +// --------------------------------------------------------------------------- +// PDF Paginated Mode +// --------------------------------------------------------------------------- + +function enterPdfPaginatedMode() { + const contentEl = $('reader-content'); + if (!contentEl) return; + contentEl.classList.add('pdf-paginated'); + contentEl.style.overflow = 'hidden'; + + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + wrappers.forEach((w, i) => { + w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; + }); + + pdfSmartZoomPage(pdfCurrentPage); + + // Tap left/right to navigate + contentEl.addEventListener('click', _pdfPaginatedClick); +} + +function exitPdfPaginatedMode() { + const contentEl = $('reader-content'); + if (!contentEl) return; + contentEl.classList.remove('pdf-paginated'); + contentEl.style.overflow = ''; + contentEl.removeEventListener('click', _pdfPaginatedClick); + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + wrappers.forEach(w => { + w.style.display = ''; + const canvas = w.querySelector('canvas'); + if (canvas) canvas.style.transform = ''; + }); +} + +function _pdfPaginatedClick(e) { + const w = e.currentTarget.clientWidth; + if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); + else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); +} + +function pdfGoToPage(n) { + if (!currentPdfDoc) return; + n = Math.max(1, Math.min(pdfTotalPages, n)); + if (n === pdfCurrentPage) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + + const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); + if (oldWrapper) oldWrapper.style.display = 'none'; + + pdfCurrentPage = n; + const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); + if (newWrapper) newWrapper.style.display = ''; + + pdfSmartZoomPage(pdfCurrentPage); + + const progressInput = $('reader-progress-input'); + if (progressInput) progressInput.value = pdfCurrentPage; +} + +async function pdfSmartZoomPage(pageNum) { + if (!currentPdfDoc) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); + if (!wrapper) return; + const canvas = wrapper.querySelector('canvas'); + if (!canvas) return; + + const page = await currentPdfDoc.getPage(pageNum); + const naturalVp = page.getViewport({scale: 1}); + const pageW = naturalVp.width; + const pageH = naturalVp.height; + + let bbox = _pdfPageTextBoxCache[pageNum]; + if (!bbox) { + bbox = await _computePdfTextBox(page, pageW, pageH); + _pdfPageTextBoxCache[pageNum] = bbox; + } + + const containerW = contentEl.clientWidth; + const containerH = contentEl.clientHeight; + const contentW = bbox.x2 - bbox.x1; + const contentH = bbox.y2 - bbox.y1; + const pad = 12; + const scale = Math.min( + (containerW - pad * 2) / contentW, + (containerH - pad * 2) / contentH + ); + + // Re-render canvas at new scale if significantly different + const currentScale = canvas.width / naturalVp.width; + if (Math.abs(scale - currentScale) / currentScale > 0.05) { + const vp = page.getViewport({scale}); + canvas.width = vp.width; + canvas.height = vp.height; + await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; + } + + // Position canvas to center the text bounding box + const renderedScale = canvas.width / naturalVp.width; + const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; + // PDF y-axis is bottom-up; canvas is top-down + const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; + canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; + wrapper.style.overflow = 'hidden'; + wrapper.style.width = containerW + 'px'; + wrapper.style.height = containerH + 'px'; +} + +async function _computePdfTextBox(page, pageW, pageH) { + // Tier 1: text-based + try { + const tc = await page.getTextContent(); + if (tc.items && tc.items.length) { + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const item of tc.items) { + if (!item.transform) continue; + const tx = item.transform[4], ty = item.transform[5]; + const iw = item.width || 0, ih = item.height || 0; + if (tx < x1) x1 = tx; + if (ty < y1) y1 = ty; + if (tx + iw > x2) x2 = tx + iw; + if (ty + ih > y2) y2 = ty + ih; + } + const area = (x2 - x1) * (y2 - y1); + if (isFinite(x1) && area > pageW * pageH * 0.25) { + return {x1, y1, x2, y2}; + } + } + } catch (e) {} + + // Tier 2: pixel analysis at scale 0.3 + try { + const lowScale = 0.3; + const vp = page.getViewport({scale: lowScale}); + const offCanvas = document.createElement('canvas'); + offCanvas.width = vp.width; + offCanvas.height = vp.height; + const ctx = offCanvas.getContext('2d'); + await page.render({canvasContext: ctx, viewport: vp}).promise; + const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); + + let rMin = height, rMax = 0, cMin = width, cMax = 0; + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const idx = (r * width + c) * 4; + if (data[idx] + data[idx+1] + data[idx+2] < 720) { + if (r < rMin) rMin = r; + if (r > rMax) rMax = r; + if (c < cMin) cMin = c; + if (c > cMax) cMax = c; + } + } + } + if (rMin < rMax && cMin < cMax) { + return { + x1: cMin / lowScale, + y1: (height - rMax) / lowScale, + x2: cMax / lowScale, + y2: (height - rMin) / lowScale, + }; + } + } catch (e) {} + + // Fallback: full page + return {x1: 0, y1: 0, x2: pageW, y2: pageH}; +} + +// --------------------------------------------------------------------------- +// Bookmarks +// --------------------------------------------------------------------------- + +async function loadBookmarks(bookId) { + try { + const res = await fetch(`/books/${bookId}/bookmarks/`); + const {ct, iv} = await res.json(); + if (ct) { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); + } else { + currentBookmarks = []; + } + } catch (e) { + currentBookmarks = []; + } +} + +async function saveBookmarks() { + if (!currentBookId) return; + try { + const key = await getOrCreateEncKey(); + const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks)); + const {iv, ciphertext} = await encryptBytes(key, plain); + const body = JSON.stringify({ct: ciphertext, iv}); + const url = `/books/${currentBookId}/bookmarks/`; + _lastBookmarkBeacon = {url, body}; + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body, + }); + bookmarksDirty = false; + } catch (e) {} +} + +function addBookmark() { + const contentEl = $('reader-content'); + if (!contentEl || !currentBookId) return; + + let label, anchor, scrollFraction; + + if (currentPdfDoc) { + const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; + label = `Page ${page}`; + anchor = `pdf-page-${page}`; + scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); + } else { + // Find first visible chapter div + const chapters = contentEl.querySelectorAll('[data-epub-src]'); + let visibleChapter = null; + for (const ch of chapters) { + const rect = ch.getBoundingClientRect(); + if (rect.bottom > 0 && rect.top < window.innerHeight) { + visibleChapter = ch; + break; + } + } + const src = visibleChapter?.getAttribute('data-epub-src') || ''; + label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark'; + anchor = src; + scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); + } + + const bm = { + id: crypto.randomUUID(), + label, + anchor, + scrollFraction, + createdAt: new Date().toISOString(), + }; + currentBookmarks.unshift(bm); + bookmarksDirty = true; + saveBookmarks(); + + // Toast + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = `★ Bookmarked: ${label}`; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2200); +} + +function openBookmarksSidebar() { + if (!currentBookmarks.length) { + openSidebar('Bookmarks', '

    No bookmarks yet. Press ★ while reading.

    '); + return; + } + let html = '
      '; + for (const bm of currentBookmarks) { + html += `
    • + + +
    • `; + } + html += '
    '; + openSidebar('Bookmarks', html); + + const body = $('sidebar-body'); + body.addEventListener('click', function _bmClick(e) { + const jumpBtn = e.target.closest('[data-jump-bookmark]'); + const delBtn = e.target.closest('[data-delete-bookmark]'); + if (jumpBtn) { + body.removeEventListener('click', _bmClick); + jumpToBookmark(jumpBtn.dataset.jumpBookmark); + } + if (delBtn) { + const id = delBtn.dataset.deleteBookmark; + currentBookmarks = currentBookmarks.filter(b => b.id !== id); + bookmarksDirty = true; + saveBookmarks(); + openBookmarksSidebar(); // re-render + } + }); +} + +function jumpToBookmark(id) { + const bm = currentBookmarks.find(b => b.id === id); + if (!bm) return; + closeSidebar(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + if (bm.anchor.startsWith('pdf-page-')) { + if (readerSettings.pdfPaginated) { + pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); + } else { + const target = contentEl.querySelector('#' + bm.anchor); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + } + } else { + const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) + .find(el => el.getAttribute('data-epub-src') === bm.anchor); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } else { + contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight); + } + } + }, 50); +} + +// --------------------------------------------------------------------------- +// Reader Search +// --------------------------------------------------------------------------- + +let _readerSearchDebounce = null; + +function toggleReaderSearch() { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + if (!overlay || !contentEl) return; + + const existing = document.getElementById('reader-search-bar'); + if (existing) { + existing.remove(); + readerSearchOpen = false; + clearReaderSearch(); + return; + } + + readerSearchOpen = true; + const bar = document.createElement('div'); + bar.id = 'reader-search-bar'; + bar.className = 'reader-search-bar'; + bar.innerHTML = ` + + + + + + `; + overlay.insertBefore(bar, contentEl); + + const input = bar.querySelector('#reader-search-input'); + input.focus(); + input.addEventListener('input', () => { + clearTimeout(_readerSearchDebounce); + _readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300); + }); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); } + if (e.key === 'Escape') { toggleReaderSearch(); } + }); + bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev); + bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext); + bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch); +} + +async function doReaderSearch(query) { + const contentEl = $('reader-content'); + if (!contentEl) return; + const countEl = document.getElementById('rs-search-count'); + + clearReaderSearchHighlights(); + searchMatches = []; + searchMatchIndex = -1; + + if (!query) { if (countEl) countEl.textContent = ''; return; } + + if (!currentPdfDoc) { + // EPUB: snapshot original content + if (!searchOriginalContent) { + searchOriginalContent = contentEl.innerHTML; + } else { + contentEl.innerHTML = searchOriginalContent; + applyHighlightsToContent(); + } + + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); + const lq = query.toLowerCase(); + const ranges = []; + let node; + while ((node = walker.nextNode())) { + const text = node.textContent; + const lt = text.toLowerCase(); + let idx = 0; + while ((idx = lt.indexOf(lq, idx)) !== -1) { + const range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + query.length); + ranges.push(range); + idx += query.length; + } + } + + // Insert marks in reverse to preserve range validity + for (let i = ranges.length - 1; i >= 0; i--) { + try { + const mark = document.createElement('mark'); + mark.className = 'reader-search-match'; + ranges[i].surroundContents(mark); + searchMatches.unshift(mark); + } catch (e) {} + } + } else { + // PDF: collect text layer spans + const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); + const lq = query.toLowerCase(); + for (const span of spans) { + if (span.textContent.toLowerCase().includes(lq)) { + span.classList.add('reader-search-match'); + searchMatches.push(span); + } + } + } + + if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; + if (searchMatches.length) { + searchMatchIndex = 0; + scrollToSearchMatch(0); + } +} + +function clearReaderSearchHighlights() { + if (!currentPdfDoc) { + // EPUB: restore from snapshot + if (searchOriginalContent !== null) { + const contentEl = $('reader-content'); + if (contentEl) { + contentEl.innerHTML = searchOriginalContent; + applyHighlightsToContent(); + } + searchOriginalContent = null; + } else { + // Just remove marks without full restore + document.querySelectorAll('mark.reader-search-match').forEach(m => { + const parent = m.parentNode; + while (m.firstChild) parent.insertBefore(m.firstChild, m); + parent.removeChild(m); + }); + } + } else { + // PDF: remove highlight class from spans + document.querySelectorAll('.reader-search-match').forEach(el => { + el.classList.remove('reader-search-match', 'active'); + }); + } + searchMatches = []; + searchMatchIndex = -1; +} + +function clearReaderSearch() { + clearTimeout(_readerSearchDebounce); + clearReaderSearchHighlights(); + readerSearchOpen = false; + const countEl = document.getElementById('rs-search-count'); + if (countEl) countEl.textContent = ''; +} + +function scrollToSearchMatch(idx) { + if (!searchMatches.length) return; + searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); + searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); + const countEl = document.getElementById('rs-search-count'); + if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; +} + +function readerSearchNext() { + if (!searchMatches.length) return; + searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; + scrollToSearchMatch(searchMatchIndex); +} + +function readerSearchPrev() { + if (!searchMatches.length) return; + searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length; + scrollToSearchMatch(searchMatchIndex); +} + +// --------------------------------------------------------------------------- +// Highlights +// --------------------------------------------------------------------------- + +async function loadHighlights(bookId) { + try { + const res = await fetch(`/books/${bookId}/highlights/`); + const {ct, iv} = await res.json(); + if (ct) { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + currentHighlights = JSON.parse(new TextDecoder().decode(plain)); + } else { + currentHighlights = []; + } + applyHighlightsToContent(); + } catch (e) { + currentHighlights = []; + } +} + +async function saveHighlights() { + if (!currentBookId) return; + try { + const key = await getOrCreateEncKey(); + const plain = new TextEncoder().encode(JSON.stringify(currentHighlights)); + const {iv, ciphertext} = await encryptBytes(key, plain); + const body = JSON.stringify({ct: ciphertext, iv}); + const url = `/books/${currentBookId}/highlights/`; + _lastHighlightBeacon = {url, body}; + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body, + }); + highlightsDirty = false; + } catch (e) {} +} + +let _highlightSaveDebounce = null; +function debounceSaveHighlights() { + clearTimeout(_highlightSaveDebounce); + _highlightSaveDebounce = setTimeout(saveHighlights, 2000); +} + +function applyHighlightsToContent() { + const contentEl = $('reader-content'); + if (!contentEl || currentPdfDoc) return; + for (const h of currentHighlights) { + try { renderHighlight(h); } catch (e) {} + } +} + +function renderHighlight(h) { + const contentEl = $('reader-content'); + if (!contentEl || !h.anchor) return; + + const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`) + || contentEl; + + let range = null; + try { + const startNode = xpathToNode(h.anchor.startXpath, chapterEl); + const endNode = xpathToNode(h.anchor.endXpath, chapterEl); + if (startNode && endNode) { + range = document.createRange(); + range.setStart(startNode, h.anchor.startOffset); + range.setEnd(endNode, h.anchor.endOffset); + } + } catch (e) {} + + // Fallback: quote substring search + if (!range && h.anchor.quote) { + const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const idx = node.textContent.indexOf(h.anchor.quote); + if (idx !== -1) { + range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + h.anchor.quote.length); + break; + } + } + } + + if (!range) return; + + try { + const mark = document.createElement('mark'); + mark.className = 'epub-highlight'; + mark.dataset.highlightId = h.id; + mark.dataset.color = h.color || 'yellow'; + range.surroundContents(mark); + } catch (e) {} +} + +function xpathToNode(xpath, root) { + if (!xpath) return null; + const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); + return result.singleNodeValue; +} + +function getXPathForNode(node, root) { + const parts = []; + let current = node; + while (current && current !== root) { + const parent = current.parentNode; + if (!parent) break; + if (current.nodeType === Node.TEXT_NODE) { + const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); + const idx = siblings.indexOf(current); + parts.unshift(`text()[${idx + 1}]`); + } else { + const siblings = Array.from(parent.children).filter(n => n.tagName === current.tagName); + const idx = siblings.indexOf(current); + parts.unshift(`${current.tagName.toLowerCase()}[${idx + 1}]`); + } + current = parent; + } + return parts.join('/'); +} + +function buildEpubAnchor(range) { + const contentEl = $('reader-content'); + const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE + ? range.commonAncestorContainer.closest('[data-epub-src]') + : range.commonAncestorContainer.parentElement?.closest('[data-epub-src]'); + const root = chapterEl || contentEl; + + return { + type: 'epub', + chapterSrc: chapterEl?.getAttribute('data-epub-src') || '', + startXpath: getXPathForNode(range.startContainer, root), + startOffset: range.startOffset, + endXpath: getXPathForNode(range.endContainer, root), + endOffset: range.endOffset, + quote: range.toString().slice(0, 200), + }; +} + +function handleReaderSelection(e) { + // If clicking an existing highlight, show tooltip + const hlMark = e.target.closest('.epub-highlight'); + if (hlMark) { + dismissHighlightPopover(); + const id = hlMark.dataset.highlightId; + const h = currentHighlights.find(x => x.id === id); + showHighlightTooltip(hlMark, h); + return; + } + + dismissHighlightPopover(); + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.rangeCount) return; + const range = sel.getRangeAt(0); + const contentEl = $('reader-content'); + if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return; + if (range.toString().trim().length === 0) return; + + showHighlightPopover(range); +} + +function showHighlightPopover(range) { + const rect = range.getBoundingClientRect(); + const popover = document.createElement('div'); + popover.id = 'highlight-popover'; + popover.className = 'highlight-popover'; + popover.innerHTML = ` + + + + + + `; + popover.style.top = (rect.top + window.scrollY - 44) + 'px'; + popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px'; + document.body.appendChild(popover); + currentHighlightPopover = popover; + + // Store range info before selection is cleared + const savedRange = range.cloneRange(); + + popover.addEventListener('click', e => { + const colorBtn = e.target.closest('.hl-color-btn'); + const noteBtn = e.target.closest('.hl-note-btn'); + if (colorBtn) { + createHighlight(colorBtn.dataset.hlColor, savedRange); + } else if (noteBtn) { + createHighlightWithNote(savedRange); + } + }); +} + +function showHighlightTooltip(markEl, h) { + const rect = markEl.getBoundingClientRect(); + const popover = document.createElement('div'); + popover.id = 'highlight-popover'; + popover.className = 'highlight-popover'; + popover.style.flexDirection = 'column'; + popover.style.maxWidth = '220px'; + const noteText = h?.note ? escapeHtml(h.note) : 'No note'; + popover.innerHTML = ` +
    ${noteText}
    +
    + + +
    + `; + popover.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + popover.style.left = (rect.left + window.scrollX) + 'px'; + document.body.appendChild(popover); + currentHighlightPopover = popover; + + popover.addEventListener('click', ev => { + const editBtn = ev.target.closest('[data-hl-edit-note]'); + const delBtn = ev.target.closest('[data-hl-delete]'); + if (editBtn && h) { + dismissHighlightPopover(); + openNoteEditor(h); + } + if (delBtn && h) { + dismissHighlightPopover(); + deleteHighlight(h.id); + } + }); + + // Close on outside click + setTimeout(() => { + document.addEventListener('click', dismissHighlightPopover, {once: true}); + }, 0); +} + +function createHighlight(color, range) { + const anchor = buildEpubAnchor(range); + const h = { + id: crypto.randomUUID(), + anchor, + color, + note: '', + createdAt: new Date().toISOString(), + }; + currentHighlights.push(h); + highlightsDirty = true; + window.getSelection()?.removeAllRanges(); + dismissHighlightPopover(); + renderHighlight(h); + debounceSaveHighlights(); +} + +function createHighlightWithNote(range) { + const anchor = buildEpubAnchor(range); + const h = { + id: crypto.randomUUID(), + anchor, + color: 'yellow', + note: '', + createdAt: new Date().toISOString(), + }; + currentHighlights.push(h); + highlightsDirty = true; + window.getSelection()?.removeAllRanges(); + dismissHighlightPopover(); + renderHighlight(h); + openNoteEditor(h); +} + +function openNoteEditor(h) { + openSidebar('Edit note', ` + + + `); + const body = $('sidebar-body'); + body.addEventListener('click', function _noteClick(e) { + const btn = e.target.closest('[data-save-note]'); + if (!btn) return; + body.removeEventListener('click', _noteClick); + const text = (body.querySelector('#hl-note-input')?.value || '').trim(); + h.note = text; + highlightsDirty = true; + debounceSaveHighlights(); + closeSidebar(); + }); +} + +function deleteHighlight(id) { + currentHighlights = currentHighlights.filter(h => h.id !== id); + highlightsDirty = true; + // Re-apply all highlights after removing the deleted one + const contentEl = $('reader-content'); + if (contentEl && !currentPdfDoc) { + // Snapshot restore not available mid-session, so remove the mark manually + const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`); + if (mark) { + const parent = mark.parentNode; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + } + } + debounceSaveHighlights(); +} + +function dismissHighlightPopover() { + if (currentHighlightPopover) { + currentHighlightPopover.remove(); + currentHighlightPopover = null; + } +} + +// --------------------------------------------------------------------------- +// Focus station sidebar +// --------------------------------------------------------------------------- + +const FOCUS_STATION_PRESETS = [ + {name: 'None (no station)', url: ''}, + {name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac'}, + {name: 'SomaFM Deep Space One', url: 'https://ice5.somafm.com/deepspaceone-128-aac'}, + {name: 'SomaFM Drone Zone', url: 'https://ice5.somafm.com/dronezone-128-aac'}, + {name: 'SomaFM Space Station', url: 'https://ice5.somafm.com/spacestation-128-aac'}, + {name: 'Linn Jazz', url: 'http://radio.linnrecords.com/linnjazz.pls'}, +]; + +function openFocusStationSidebar() { + // null = never saved (default active); {url:''} = disabled; {url:'...'} = custom + const effectiveUrl = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.url : (USER_FOCUS_STATION.url || ''); + const currentName = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.name + : (USER_FOCUS_STATION.name || 'None (no station)'); + + let presetsHtml = FOCUS_STATION_PRESETS.map((p, i) => { + const active = p.url === effectiveUrl ? ' class="focus-preset-active"' : ''; + return ``; + }).join(''); + + const html = ` +

    Station played when opening a book.

    +

    Current: ${escapeHtml(currentName)}

    +
      ${presetsHtml}
    +
    + + + + +
    + `; + + openSidebar('Focus Station', html); + + const body = $('sidebar-body'); + body.addEventListener('click', function _focusClick(e) { + const presetBtn = e.target.closest('[data-focus-preset]'); + const saveBtn = e.target.closest('[data-focus-save]'); + const playBtn = e.target.closest('[data-focus-play]'); + if (presetBtn) { + const preset = FOCUS_STATION_PRESETS[parseInt(presetBtn.dataset.focusPreset, 10)]; + if (preset) saveFocusStation(preset.url, preset.name); + body.removeEventListener('click', _focusClick); + } else if (saveBtn) { + const url = (body.querySelector('#focus-custom-url')?.value || '').trim(); + const name = (body.querySelector('#focus-custom-name')?.value || '').trim(); + saveFocusStation(url, name); + body.removeEventListener('click', _focusClick); + } else if (playBtn) { + const url = (body.querySelector('#focus-custom-url')?.value || '').trim(); + const name = (body.querySelector('#focus-custom-name')?.value || '').trim(); + if (url) playStation(url, name, null); + } + }); +} + + +async function saveFocusStation(url, name) { + url = (url || '').trim(); + name = (name || '').trim(); + if (!IS_AUTHENTICATED) { + USER_FOCUS_STATION = {url, name}; + closeSidebar(); + return; + } + try { + const res = await fetch('/accounts/focus-station/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({url, name}), + }); + const data = await res.json(); + if (data.ok) { + USER_FOCUS_STATION = {url, name}; + closeSidebar(); + } + } catch (e) {} +} + // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- (function init() { + // Migrate PBKDF2-derived key stored by login/register form + if (typeof USER_ID !== 'undefined' && USER_ID) { + const pending = localStorage.getItem('diora_pending_enc_key'); + if (pending) { + localStorage.setItem(`diora_enc_key_${USER_ID}`, pending); + localStorage.removeItem('diora_pending_enc_key'); + } + } + // Populate saved stations from server-side context if available if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) { // The server already renders saved stations in the template; nothing extra needed. // But if JS-rendered saved tab were needed we'd call addSavedRow here. } + // Seed podcast feeds from server context + if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) { + podcastFeeds = INITIAL_PODCAST_FEEDS; + } + + // Wire seek slider + const seekSlider = $('seek-slider'); + if (seekSlider) { + seekSlider.addEventListener('input', function () { + if (podcastMode) audio.currentTime = parseInt(this.value, 10); + }); + } + // Restore persisted volume, fall back to slider default const volSlider = $('volume'); if (volSlider) { @@ -1121,12 +3882,15 @@ function toggleContrast() { // Load focus session stats loadFocusStats(); - // Auto-detect wallpaper brightness + best accent colour - const bgUrl = document.body.dataset.bg; - if (bgUrl) { - analyzeBackground(bgUrl).then(({ bright, bgLuminance }) => { - setScheme(bright); - applyAccent(pickBestAccent(bgLuminance)); - }); - } + // Apply encrypted wallpaper (if set) + applyEncryptedBackground(); + + // Init book drop zone + initBookDropZone(); + + // Restore last active tab + const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; + const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'search'; + showTab(savedTab); + showRadioTab(savedRadioTab); })(); diff --git a/templates/radio/player.html b/templates/radio/player.html index 946d70e..38218d9 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -18,6 +18,21 @@ + + +
    focus @@ -44,140 +59,149 @@
    - - - + + +
    - - @@ -196,6 +220,108 @@ + + + + + + + + + + + + + {% endblock %} {% block extra_js %} @@ -204,6 +330,9 @@ const INITIAL_SAVED = {{ saved_stations|safe }}; const INITIAL_FEATURED = {{ featured_stations|safe }}; const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }}; + const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }}; + const USER_ID = {{ user.id|default:"null" }}; + let USER_FOCUS_STATION = {{ focus_station_json|safe }}; {% endblock %}