From e70bb881ccd2db9aa1b4331f95fd517ebea4c8f4 Mon Sep 17 00:00:00 2001 From: marwin Date: Fri, 14 Aug 2026 23:31:01 +0200 Subject: [PATCH] Reader: schnelleres j/k-Scrollen; Offline-Modus auf Desktop repariert (SW v33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit j/k nutzte ein starres 8px/16ms-setInterval (~500px/s, unsauber getaktet gegenüber dem Frame-Takt) — ersetzt durch eine requestAnimationFrame-Schleife mit sanftem Ramp-up (700 -> 1800px/s über 300ms), das behebt sowohl die zu niedrige Geschwindigkeit als auch das Ruckeln kurz nach Scrollbeginn. Der Service Worker war unter /static/js/sw.js registriert, wodurch sein Scope (mangels Service-Worker-Allowed-Header) automatisch auf /static/js/ beschränkt war statt die ganze App abzudecken — Navigationen zu z.B. / liefen nie durch den fetch-Handler, weshalb offline nur die Browser-eigene Fehlerseite statt der App-Shell erschien. Jetzt wird sw.js zusätzlich unter der Root /sw.js ausgeliefert (diora/urls.py) und dort mit explizitem scope:'/' registriert; alte Registrierungen mit dem falschen Scope werden beim Laden automatisch entfernt. Co-Authored-By: Claude Sonnet 5 --- diora/urls.py | 7 +++++++ static/js/app.js | 49 +++++++++++++++++++++++++++++++++++++----------- static/js/sw.js | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/diora/urls.py b/diora/urls.py index 757a8b4..234e995 100644 --- a/diora/urls.py +++ b/diora/urls.py @@ -2,6 +2,7 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include +from django.views.static import serve as serve_static urlpatterns = [ path('admin/', admin.site.urls), @@ -9,5 +10,11 @@ urlpatterns = [ path('podcasts/', include('podcasts.urls')), path('books/', include('books.urls')), path('api/2/', include('gpodder.urls')), + # Served at the root (not /static/js/sw.js) so its default scope covers + # the whole app — a service worker's scope is limited to its own script's + # directory unless the server sends Service-Worker-Allowed, so registering + # it from under /static/js/ silently restricted it to that subpath and + # navigations to '/', '/books/' etc. were never intercepted while offline. + path('sw.js', serve_static, {'document_root': settings.BASE_DIR / 'static' / 'js', 'path': 'sw.js'}), path('', include('radio.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/static/js/app.js b/static/js/app.js index 37a7df8..a5e05a1 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2237,14 +2237,16 @@ document.addEventListener('keydown', e => { const contentEl = $('reader-content'); if (contentEl) { const large = Math.round(contentEl.clientHeight * 0.85); - const continuousKeys = {'j': 8, 'k': -8}; + const continuousKeys = {'j': 1, 'k': -1}; if (continuousKeys[e.key] !== undefined && !e.repeat) { e.preventDefault(); - _readerScrollStep = continuousKeys[e.key]; - if (!_readerScrollInterval) { - _readerScrollInterval = setInterval(() => { - $('reader-content')?.scrollBy({top: _readerScrollStep}); - }, 16); + const dir = continuousKeys[e.key]; + const now = performance.now(); + if (_readerScrollDir !== dir) _readerScrollStartTs = now; + _readerScrollDir = dir; + _readerScrollLastTs = now; + if (!_readerScrollRaf) { + _readerScrollRaf = requestAnimationFrame(_readerScrollTick); } } if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); } @@ -2264,8 +2266,9 @@ document.addEventListener('keydown', e => { document.addEventListener('keyup', e => { if (e.key === 'j' || e.key === 'k') { - clearInterval(_readerScrollInterval); - _readerScrollInterval = null; + if (_readerScrollRaf) cancelAnimationFrame(_readerScrollRaf); + _readerScrollRaf = null; + _readerScrollDir = 0; } }); @@ -2309,9 +2312,19 @@ function formatDuration(seconds) { if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('/static/js/sw.js').catch(err => { + // Registered from '/sw.js' (root), not '/static/js/sw.js': a service + // worker's default scope is its own script's directory, so registering + // it from under /static/js/ would only ever let it control requests + // under that path — page navigations to '/', '/books/' etc. would never + // be intercepted, breaking offline entirely. + navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => { console.warn('Service worker registration failed:', err); }); + // Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js' + // path so it doesn't keep running or fight the new root-scoped worker. + navigator.serviceWorker.getRegistrations().then(regs => { + regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); }); + }); }); // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets, // but only if the reader isn't open (would interrupt reading). @@ -3176,8 +3189,22 @@ let pdfTotalPages = 0; let _pdfPageTextBoxCache = {}; let _pdfRenderGen = 0; let _touchStartX = 0; -let _readerScrollInterval = null; -let _readerScrollStep = 0; +let _readerScrollRaf = null; +let _readerScrollDir = 0; +let _readerScrollStartTs = 0; +let _readerScrollLastTs = 0; +const READER_SCROLL_MIN_SPEED = 700; // px/s right as j/k is pressed +const READER_SCROLL_MAX_SPEED = 1800; // px/s once ramped up +const READER_SCROLL_RAMP_MS = 300; // time to reach max speed +function _readerScrollTick(ts) { + const dt = ts - _readerScrollLastTs; + _readerScrollLastTs = ts; + const held = ts - _readerScrollStartTs; + const t = Math.min(held / READER_SCROLL_RAMP_MS, 1); + const speed = READER_SCROLL_MIN_SPEED + (READER_SCROLL_MAX_SPEED - READER_SCROLL_MIN_SPEED) * t; + $('reader-content')?.scrollBy({top: _readerScrollDir * speed * dt / 1000}); + _readerScrollRaf = requestAnimationFrame(_readerScrollTick); +} let _pinchStartDist = 0; let _pinchStartZoom = 100; let _isPinching = false; diff --git a/static/js/sw.js b/static/js/sw.js index a18e60a..4ff96cd 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v32'; +const CACHE = 'diora-v33'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',