Compare commits

..

No commits in common. "master" and "testing" have entirely different histories.

21 changed files with 286 additions and 1571 deletions

3
.gitignore vendored
View file

@ -33,6 +33,3 @@ CLAUDE.md
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
antennapod-feeds-2026-03-19.opml
.gitignore
playlist.m3u

View file

@ -13,6 +13,4 @@ urlpatterns = [
path('background/upload/', views.upload_background, name='upload_background'), path('background/upload/', views.upload_background, name='upload_background'),
path('background/delete/', views.delete_background, name='delete_background'), path('background/delete/', views.delete_background, name='delete_background'),
path('focus-station/', views.save_focus_station, name='save_focus_station'), path('focus-station/', views.save_focus_station, name='save_focus_station'),
path('check-password/', views.check_password, name='check_password'),
path('change-password/', views.change_password, name='change_password'),
] ]

View file

@ -1,9 +1,9 @@
import base64 import base64
import json import json
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash from django.contrib.auth import authenticate, login, get_user_model
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -71,7 +71,6 @@ def settings_view(request):
context = { context = {
'profile': profile, 'profile': profile,
'has_lastfm': profile.has_lastfm(), 'has_lastfm': profile.has_lastfm(),
'password_form': PasswordChangeForm(request.user),
} }
return render(request, 'accounts/settings.html', context) return render(request, 'accounts/settings.html', context)
@ -182,36 +181,6 @@ def save_focus_station(request):
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def check_password(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'ok': False}, status=400)
pw = body.get('password', '')
user = authenticate(request, username=request.user.username, password=pw)
return JsonResponse({'ok': user is not None})
@login_required
@require_http_methods(['POST'])
def change_password(request):
form = PasswordChangeForm(request.user, request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
return redirect('settings')
profile = request.user.profile
return render(request, 'accounts/settings.html', {
'profile': profile,
'has_lastfm': profile.has_lastfm(),
'password_form': form,
'password_form_open': True,
})
@login_required @login_required
@require_http_methods(['POST']) @require_http_methods(['POST'])
def lastfm_disconnect(request): def lastfm_disconnect(request):

View file

@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0002_ebookbookmarks_ebookhighlights'),
]
operations = [
migrations.AddField(
model_name='ebookprogress',
name='position_anchor',
field=models.CharField(blank=True, default='', max_length=30),
),
]

View file

@ -21,7 +21,6 @@ class EBookProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0) scroll_fraction = models.FloatField(default=0.0)
position_anchor = models.CharField(max_length=30, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View file

@ -6,8 +6,6 @@ urlpatterns = [
path('upload/', views.upload_book, name='upload_book'), path('upload/', views.upload_book, name='upload_book'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'), path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'), path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'), path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'), path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'), path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),

View file

@ -1,6 +1,5 @@
import base64 import base64
import json import json
import re
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
@ -16,30 +15,6 @@ def _require_auth(request):
return None return None
def _anchor_parts(anchor):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress)."""
if not isinstance(anchor, str) or ':' not in anchor:
return (-1, 0.0)
block, _, inner = anchor.partition(':')
try:
return (int(block), float(inner))
except ValueError:
return (-1, 0.0)
def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
"""True if the new reading position is at least as far into the book as the
old one. Compares by anchor block index (precise, decoupled from layout);
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
nb, ni = _anchor_parts(new_anchor)
ob, oi = _anchor_parts(old_anchor)
if nb >= 0 and ob >= 0:
return ni >= oi if nb == ob else nb >= ob
return new_frac >= old_frac
@require_http_methods(['GET']) @require_http_methods(['GET'])
def book_list(request): def book_list(request):
err = _require_auth(request) err = _require_auth(request)
@ -52,14 +27,13 @@ def book_list(request):
b['uploaded_at'] = b['uploaded_at'].isoformat() b['uploaded_at'] = b['uploaded_at'].isoformat()
# Include saved scroll_fraction for each book # Include saved scroll_fraction for each book
progress_map = { progress_map = {
p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor) p.book_id: (p.scroll_fraction, p.updated_at)
for p in EBookProgress.objects.filter(user=request.user) for p in EBookProgress.objects.filter(user=request.user)
} }
for b in books: for b in books:
prog = progress_map.get(b['id']) prog = progress_map.get(b['id'])
b['scroll_fraction'] = prog[0] if prog else 0.0 b['scroll_fraction'] = prog[0] if prog else 0.0
b['last_read'] = prog[1].isoformat() if prog else None b['last_read'] = prog[1].isoformat() if prog else None
b['position_anchor'] = prog[2] if prog else ''
return JsonResponse(books, safe=False) return JsonResponse(books, safe=False)
@ -91,7 +65,7 @@ def upload_book(request):
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400) return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes: if raw_size > max_bytes:
return JsonResponse({'error': 'file too large (max 50 MB)'}, status=400) return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
book = EBook.objects.create( book = EBook.objects.create(
user=request.user, user=request.user,
@ -100,7 +74,6 @@ def upload_book(request):
data_ct=data_ct, data_ct=data_ct,
data_iv=data_iv, data_iv=data_iv,
) )
EBookProgress.objects.create(user=request.user, book=book)
return JsonResponse({'ok': True, 'id': book.id}) return JsonResponse({'ok': True, 'id': book.id})
@ -118,75 +91,6 @@ def get_book_data(request, pk):
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv}) return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
@csrf_exempt
@require_http_methods(['POST'])
def replace_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)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
update_fields = ['data_ct', 'data_iv']
book.data_ct = data_ct
book.data_iv = data_iv
if meta_ct and meta_iv:
book.meta_ct = meta_ct
book.meta_iv = meta_iv
update_fields += ['meta_ct', 'meta_iv']
book.save(update_fields=update_fields)
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def rekey_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)
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)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.data_ct = data_ct
book.data_iv = data_iv
book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
return JsonResponse({'ok': True})
@csrf_exempt @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def delete_book(request, pk): def delete_book(request, pk):
@ -223,25 +127,12 @@ def save_progress(request, pk):
scroll_fraction = float(body.get('scroll_fraction', 0.0)) scroll_fraction = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction)) scroll_fraction = max(0.0, min(1.0, scroll_fraction))
raw_anchor = body.get('position_anchor', '')
position_anchor = ''
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor
force = bool(body.get('force', False))
progress, _ = EBookProgress.objects.get_or_create( progress, _ = EBookProgress.objects.get_or_create(
user=request.user, user=request.user,
book=book, book=book,
) )
# Always advance to the furthest-read position (by anchor block index, so a progress.scroll_fraction = scroll_fraction
# transiently wrong scroll_fraction can't freeze the position) unless the progress.save(update_fields=['scroll_fraction', 'updated_at'])
# client explicitly forces a reset (e.g. "start over" button in the reader).
if force or _progress_is_further(position_anchor, scroll_fraction,
progress.position_anchor, progress.scroll_fraction):
progress.scroll_fraction = scroll_fraction
progress.position_anchor = position_anchor
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})
@ -281,7 +172,7 @@ def book_highlights(request, pk):
raw_size = len(base64.b64decode(ct)) raw_size = len(base64.b64decode(ct))
except Exception: except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400) return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'HIGHLIGHTS_MAX_BYTES', 700 * 1024): if raw_size > 700 * 1024:
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400) return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book) row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
@ -326,7 +217,7 @@ def book_bookmarks(request, pk):
raw_size = len(base64.b64decode(ct)) raw_size = len(base64.b64decode(ct))
except Exception: except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400) return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'BOOKMARKS_MAX_BYTES', 100 * 1024): if raw_size > 100 * 1024:
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400) return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book) row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)

View file

@ -3,11 +3,3 @@ from django.conf import settings
def build_info(request): def build_info(request):
return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')} return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')}
def upload_limits(request):
return {
'EBOOK_MAX_BYTES': getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024),
'BG_MAX_BYTES': getattr(settings, 'BG_MAX_BYTES', 5 * 1024 * 1024),
'PODCAST_INBOX_PAGE_SIZE': getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200),
}

View file

@ -32,10 +32,10 @@ INSTALLED_APPS = [
'gpodder', 'gpodder',
] ]
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~75 MB body # Encrypted uploads are base64-encoded (~33% overhead) so allow ~25 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 75 * 1024 * 1024 DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
@ -62,7 +62,6 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'diora.context_processors.build_info', 'diora.context_processors.build_info',
'diora.context_processors.upload_limits',
], ],
}, },
}, },
@ -101,13 +100,7 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255
ITUNES_TIMEOUT = 6 # seconds
PODCAST_INBOX_PAGE_SIZE = 200
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View file

@ -143,7 +143,7 @@ def subscriptions_by_device(request, username, deviceid):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []}) return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else: else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True)) urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []}) return JsonResponse(urls, safe=False)
elif request.method == 'POST': elif request.method == 'POST':
try: try:
@ -178,7 +178,7 @@ def subscriptions_all(request, username):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []}) return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else: else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True)) urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []}) return JsonResponse(urls, safe=False)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -242,7 +242,7 @@ def episode_actions(request, username):
except Exception: except Exception:
pass pass
return JsonResponse({'timestamp': _now_ts(), 'update_urls': []}) return JsonResponse({'timestamp': _now_ts()})
elif request.method == 'GET': elif request.method == 'GET':
since = request.GET.get('since') since = request.GET.get('since')

View file

@ -145,7 +145,7 @@ def podcast_search(request):
try: try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20' url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
resp = requests.get(url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6)) resp = requests.get(url, timeout=6)
resp.raise_for_status() resp.raise_for_status()
raw = resp.json().get('results', []) raw = resp.json().get('results', [])
except Exception as e: except Exception as e:
@ -620,7 +620,7 @@ def inbox(request):
).values_list('episode_id', flat=True) ).values_list('episode_id', flat=True)
) )
limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000) limit = min(int(request.GET.get('limit', 200)), 1000)
offset = max(int(request.GET.get('offset', 0)), 0) offset = max(int(request.GET.get('offset', 0)), 0)
episodes = list( episodes = list(

View file

@ -18,5 +18,4 @@ urlpatterns = [
path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'), path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'),
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'), path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
path('radio/focus/stats/', views.focus_stats, name='focus_stats'), path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
path('radio/stream-player/', views.stream_player, name='stream_player'),
] ]

View file

@ -1,6 +1,4 @@
import json import json
import socket
import ssl as ssl_module
import time import time
import urllib.parse import urllib.parse
from datetime import datetime from datetime import datetime
@ -206,7 +204,7 @@ def affiliate_links(request):
f"https://itunes.apple.com/search" f"https://itunes.apple.com/search"
f"?term={urllib.parse.quote(track)}&media=music&limit=1" f"?term={urllib.parse.quote(track)}&media=music&limit=1"
) )
resp = requests.get(itunes_url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6)) resp = requests.get(itunes_url, timeout=5)
resp.raise_for_status() resp.raise_for_status()
results = resp.json().get('results', []) results = resp.json().get('results', [])
if results: if results:
@ -575,19 +573,3 @@ def import_m3u(request):
skipped += 1 skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped}) return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Minimal HTTP stream player (standalone tab for mixed-content streams)
# ---------------------------------------------------------------------------
def stream_player(request):
url = request.GET.get('url', '').strip()
name = request.GET.get('name', '').strip()
_vol_default = getattr(settings, 'VOLUME_DEFAULT', 204)
vol = request.GET.get('vol', str(_vol_default)).strip()
try:
vol = max(0, min(255, int(vol)))
except ValueError:
vol = _vol_default
return render(request, 'radio/stream_player.html', {'stream_url': url, 'stream_name': name, 'stream_vol': vol})

View file

@ -8,17 +8,6 @@
padding: 0; padding: 0;
} }
html, body {
height: 100%;
overflow: hidden;
scrollbar-color: #333 #000;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #000; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
:root { :root {
--bg: #000; --bg: #000;
--bg-card: transparent; --bg-card: transparent;
@ -107,16 +96,8 @@ a:hover {
justify-content: space-between; justify-content: space-between;
height: var(--nav-h); height: var(--nav-h);
padding: 0 1.5rem; padding: 0 1.5rem;
background: var(--bg); background: transparent;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
/* Window Controls Overlay: extend navbar into the titlebar area */
padding-left: max(1.5rem, env(titlebar-area-x, 1.5rem));
padding-top: env(titlebar-area-y, 0);
height: calc(var(--nav-h) + env(titlebar-area-y, 0px));
-webkit-app-region: drag;
}
.navbar a, .navbar button, .navbar input, .navbar form {
-webkit-app-region: no-drag;
} }
.navbar-brand { .navbar-brand {
@ -149,9 +130,7 @@ a:hover {
.main-content { .main-content {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: calc(var(--nav-h) + 3rem) 1.5rem calc(var(--bar-h) + 2rem); padding: 1rem 1.5rem calc(var(--bar-h) + 2rem);
height: 100%;
overflow-y: auto;
} }
/* ========================================================= /* =========================================================
@ -183,7 +162,7 @@ a:hover {
left: 0; left: 0;
right: 0; right: 0;
height: var(--bar-h); height: var(--bar-h);
background: var(--bg); background: transparent;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
display: flex; display: flex;
align-items: center; align-items: center;
@ -315,18 +294,6 @@ a:hover {
gap: 0; gap: 0;
} }
/* Haupttab-Leiste: immer sichtbar unterhalb der Navbar */
#tabs {
position: fixed;
top: var(--nav-h);
left: 0;
right: 0;
z-index: 150;
background: var(--bg);
margin-bottom: 0;
padding: 0 0.5rem;
}
.tab-btn { .tab-btn {
background: none; background: none;
border: none; border: none;
@ -748,10 +715,7 @@ a:hover {
@media (max-width: 600px) { @media (max-width: 600px) {
.main-content { .main-content {
padding: calc(var(--nav-h) + 3rem) 0.75rem calc(var(--bar-h) + 1.5rem); padding: 0.75rem 0.75rem calc(var(--bar-h) + 1.5rem);
}
#tabs {
padding: 0 0.25rem;
} }
.now-playing-bar { .now-playing-bar {
@ -770,10 +734,8 @@ a:hover {
justify-content: space-between; justify-content: space-between;
} }
.volume-label, .volume-slider {
.volume-slider, width: 60px;
.volume-num {
display: none;
} }
.affiliate-section { .affiliate-section {
@ -1454,13 +1416,6 @@ body.dnd-mode .timer-display {
.podcast-thumb-lg { width: 60px; height: 60px; } .podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-actions { flex-direction: column; } .podcast-feed-actions { flex-direction: column; }
.episode-actions { flex-direction: column; } .episode-actions { flex-direction: column; }
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
.reader-header { padding: 6px 8px; gap: 4px; }
.reader-title { font-size: 13px; }
.reader-header-actions { gap: 4px; }
.reader-progress-wrap { display: none; }
.reader-content { padding: 16px 10px; }
} }
/* ========================================================= /* =========================================================
@ -1495,12 +1450,6 @@ body.dnd-mode .timer-display {
padding: 24px 0 16px; padding: 24px 0 16px;
} }
.offline-notice {
font-size: 13px;
color: var(--accent);
padding: 4px 0 8px;
}
.book-key-bar { .book-key-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1603,8 +1552,6 @@ body.dnd-mode .timer-display {
.reader-content { .reader-content {
flex: 1; flex: 1;
overflow-y: scroll; overflow-y: scroll;
overflow-x: auto;
position: relative;
padding: 24px 16px; padding: 24px 16px;
line-height: 1.8; line-height: 1.8;
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
@ -1625,13 +1572,23 @@ body.dnd-mode .timer-display {
} }
/* --- Focus station sidebar --- */ /* --- Focus station sidebar --- */
/* --- Radio sidebar --- */ .focus-preset-list {
.rsb-nowplaying { margin-bottom: 12px; } list-style: none;
.rsb-station-name { font-weight: 600; } display: flex;
.rsb-track { font-size: 0.85rem; margin-top: 2px; } flex-direction: column;
.rsb-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } gap: 6px;
.rsb-vol { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted, #888); } margin: 10px 0;
.rsb-station-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } }
.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 --- */ /* --- Table of contents sidebar --- */
.toc-list { .toc-list {
@ -1696,71 +1653,17 @@ body.dnd-mode .timer-display {
/* PDF invert */ /* PDF invert */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); } #reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
/* ---- Immersive reader mode ---- */
.navbar { transition: transform 0.35s ease; }
.now-playing-bar{ transition: transform 0.35s ease; }
.reader-overlay { transition: top 0.35s ease, bottom 0.35s ease; }
.reader-header { overflow: hidden; max-height: 60px;
transition: max-height 0.35s ease, padding-top 0.35s ease, padding-bottom 0.35s ease; }
body.reader-immersive .navbar { transform: translateY(-100%); }
body.reader-immersive .now-playing-bar { transform: translateY(100%); }
body.reader-immersive .reader-overlay { top: 0; bottom: 0; }
body.reader-immersive .reader-header { max-height: 0; padding-top: 0; padding-bottom: 0; }
body.reader-immersive.reader-show-top .navbar { transform: none; }
body.reader-immersive.reader-show-top .reader-overlay { top: var(--nav-h); }
body.reader-immersive.reader-show-top .reader-header { max-height: 60px; padding-top: 8px; padding-bottom: 8px; }
body.reader-immersive.reader-show-bottom .now-playing-bar { transform: none; }
body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h); }
/* PDF paginated */ /* PDF paginated */
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; } .reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
.pdf-paginated .pdf-page-wrapper { margin:0; } .pdf-paginated .pdf-page-wrapper { margin:0; }
/* PDF loading overlay */
.pdf-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 20, 0.85);
z-index: 200;
}
.pdf-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: var(--accent, #e63946);
border-radius: 50%;
animation: pdf-spin 0.7s linear infinite;
}
@keyframes pdf-spin { to { transform: rotate(360deg); } }
/* PDF two-page spread */
.pdf-spread-wrapper { display:flex; flex-direction:row; gap:8px; justify-content:center; margin-bottom:1rem; }
.pdf-spread-wrapper .pdf-page-wrapper { margin:0; }
.pdf-spread-cover { margin-bottom:1rem; }
/* Disable text selection during pinch */
#reader-content.pinch-active { user-select:none; -webkit-user-select:none; }
/* Highlight popover */ /* 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); } .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 { 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-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; } .hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Footnote popover */
.footnote-popover { position:fixed; z-index:500; max-width:min(400px,90vw); max-height:220px; overflow-y:auto; background:#1a1a1a; color:#e8e8e8; border:1px solid #444; border-radius:var(--radius); padding:10px 28px 10px 12px; box-shadow:0 4px 20px rgba(0,0,0,.6); font-size:0.88em; line-height:1.55; pointer-events:auto; }
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Highlight marks */ /* Highlight marks */
.reader-no-bold * { font-weight: normal !important; }
.reader-content img { max-width: 100%; height: auto; display: block; }
.epub-highlight { border-radius:2px; cursor:pointer; } .epub-highlight { border-radius:2px; cursor:pointer; }
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); } .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="green"] { background:rgba(46,204,113,.35); }

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v15'; const CACHE = 'diora-v7';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
@ -31,13 +31,6 @@ self.addEventListener('activate', function (event) {
); );
}).then(function () { }).then(function () {
return self.clients.claim(); return self.clients.claim();
}).then(function () {
// Tell all open tabs to reload so they get the latest cached assets
return self.clients.matchAll({type: 'window'}).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({type: 'SW_ACTIVATED'});
});
});
}) })
); );
}); });
@ -78,7 +71,7 @@ self.addEventListener('fetch', function (event) {
return; return;
} }
// Cache-first for pre-defined shell assets // Cache-first only for pre-defined shell assets; everything else hits the network
const isShell = SHELL.some(function (s) { return url.pathname === s; }); const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) { if (isShell) {
event.respondWith( event.respondWith(
@ -86,22 +79,5 @@ self.addEventListener('fetch', function (event) {
return cached || fetch(event.request); return cached || fetch(event.request);
}) })
); );
return;
}
// Navigation requests (the HTML page itself): network-first, fall back to
// last cached version so the app opens offline after being visited once.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).then(function (response) {
if (response.ok) {
var clone = response.clone();
caches.open(CACHE).then(function (cache) { cache.put(event.request, clone); });
}
return response;
}).catch(function () {
return caches.match(event.request);
})
);
} }
}); });

View file

@ -4,7 +4,6 @@
"description": "Internet radio player", "description": "Internet radio player",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#000000", "background_color": "#000000",
"theme_color": "#000000", "theme_color": "#000000",
"orientation": "any", "orientation": "any",
@ -13,25 +12,13 @@
"src": "/static/icon-192.png", "src": "/static/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any maskable"
},
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}, },
{ {
"src": "/static/icon-512.png", "src": "/static/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

View file

@ -72,29 +72,7 @@
<section class="settings-section"> <section class="settings-section">
<h2>Account</h2> <h2>Account</h2>
<p>Logged in as <strong>{{ request.user.username }}</strong></p> <p>Logged in as <strong>{{ request.user.username }}</strong></p>
<form method="post" action="{% url 'logout' %}" class="inline-form">
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
{% csrf_token %}
{% for field in password_form %}
<div>
<label style="display:block; font-size:0.85rem; margin-bottom:2px;">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="message message-error" style="margin-top:4px; padding:4px 8px; font-size:0.8rem;">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
{% if password_form.non_field_errors %}
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
{% endif %}
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
<div><button type="submit" class="btn">Save</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn">Logout</button> <button type="submit" class="btn">Logout</button>
</form> </form>
@ -143,11 +121,6 @@ async function _getOrCreateEncKey() {
return key; return key;
} }
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
const ok = confirm('Nach dem Ändern des Passworts können alle hochgeladenen Bücher nicht mehr geöffnet werden und müssen erneut hochgeladen werden. Fortfahren?');
if (!ok) e.preventDefault();
});
async function uploadBackground(input) { async function uploadBackground(input) {
const file = input.files[0]; const file = input.files[0];
if (!file) return; if (!file) return;

View file

@ -13,7 +13,6 @@
<link rel="apple-touch-icon" href="/static/icon-192.png"> <link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
<title>{% block title %}diora{% endblock %}</title> <title>{% block title %}diora{% endblock %}</title>
<script>const DIORA_CONFIG = { ebookMaxBytes: {{ EBOOK_MAX_BYTES }}, bgMaxBytes: {{ BG_MAX_BYTES }}, podcastInboxPageSize: {{ PODCAST_INBOX_PAGE_SIZE }} };</script>
{% if encrypted_bg_json %} {% if encrypted_bg_json %}
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script> <script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
{% endif %} {% endif %}

View file

@ -18,7 +18,7 @@
</label> </label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button> <button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button> <button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">📻</button> <button class="btn-icon" id="focus-station-btn" onclick="openFocusStationSidebar()" title="Focus station">📻</button>
</div> </div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;"> <div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button> <button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button>
@ -338,12 +338,11 @@
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;"> <input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span> <span id="reader-progress-suffix" class="muted"></span>
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button> <button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button> <button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button> <button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button> <button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button> <button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)"></button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button> <button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div> </div>
</div> </div>

View file

@ -1,162 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ stream_name|default:"Radio" }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d0d0d;
color: #e0e0e0;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
}
#station-name {
font-size: 1.3rem;
font-weight: 700;
text-align: center;
}
#track-name {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.2em;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
#play-btn {
background: #e63946;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 1rem;
cursor: pointer;
min-width: 90px;
}
#play-btn:hover { background: #c1121f; }
.vol-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #888;
}
#vol-slider { width: 90px; accent-color: #e63946; }
#vol-num {
width: 42px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
padding: 2px 4px;
font-size: 0.85rem;
text-align: center;
}
#back-btn {
background: none;
border: none;
color: #555;
font-size: 0.8rem;
cursor: pointer;
text-decoration: underline;
margin-top: 8px;
}
#back-btn:hover { color: #aaa; }
</style>
</head>
<body>
<div id="station-name">{{ stream_name|default:"Radio" }}</div>
<div id="track-name"></div>
<div class="controls">
<button id="play-btn">&#9654; Play</button>
<div class="vol-wrap">
<span>vol</span>
<input type="range" id="vol-slider" min="0" max="255" value="{{ stream_vol }}">
<input type="number" id="vol-num" min="0" max="255" value="{{ stream_vol }}">
</div>
</div>
<button id="back-btn" onclick="window.close()">&#8592; close tab</button>
<script>
const audio = new Audio();
let playing = false;
let sse = null;
const streamUrl = '{{ stream_url|escapejs }}';
const stationName = '{{ stream_name|escapejs }}';
// Volume
function setVol(v) {
v = Math.max(0, Math.min(255, Math.round(v)));
audio.volume = v / 255;
document.getElementById('vol-slider').value = v;
document.getElementById('vol-num').value = v;
try { localStorage.setItem('diora_volume', v); } catch (_) {}
}
const slider = document.getElementById('vol-slider');
const numIn = document.getElementById('vol-num');
slider.addEventListener('input', () => setVol(parseInt(slider.value, 10)));
numIn.addEventListener('change', () => setVol(parseInt(numIn.value, 10)));
// Play / Stop
const playBtn = document.getElementById('play-btn');
function startPlay() {
audio.src = streamUrl;
setVol(parseInt(slider.value, 10));
audio.play().then(() => {
playing = true;
playBtn.innerHTML = '&#9646;&#9646; Stop';
if (sse) sse.close();
sse = new EventSource('/radio/sse/?url=' + encodeURIComponent(streamUrl));
sse.onmessage = e => {
try {
const data = JSON.parse(e.data);
if (data.track) {
document.getElementById('track-name').textContent = data.track;
document.title = data.track + ' — ' + stationName;
}
} catch (_) {}
};
}).catch(() => {
// Autoplay blocked — reset state, prompt user to click
playing = false;
audio.src = '';
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = 'Click Play to start';
});
}
function stopPlay() {
audio.pause();
audio.src = '';
playing = false;
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = '';
document.title = stationName;
if (sse) { sse.close(); sse = null; }
}
playBtn.addEventListener('click', () => {
if (playing) stopPlay(); else startPlay();
});
document.getElementById('track-name').textContent = 'Click Play to start';
</script>
</body>
</html>