Reader: schnelleres j/k-Scrollen; Offline-Modus auf Desktop repariert (SW v33)
All checks were successful
Build and push Docker image / build (push) Successful in 50s
Test / test (push) Successful in 16s

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 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-14 23:31:01 +02:00
parent d3c435dd59
commit e70bb881cc
3 changed files with 46 additions and 12 deletions

View file

@ -2,6 +2,7 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.views.static import serve as serve_static
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
@ -9,5 +10,11 @@ urlpatterns = [
path('podcasts/', include('podcasts.urls')), path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')), path('books/', include('books.urls')),
path('api/2/', include('gpodder.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')), path('', include('radio.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -2237,14 +2237,16 @@ document.addEventListener('keydown', e => {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (contentEl) { if (contentEl) {
const large = Math.round(contentEl.clientHeight * 0.85); 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) { if (continuousKeys[e.key] !== undefined && !e.repeat) {
e.preventDefault(); e.preventDefault();
_readerScrollStep = continuousKeys[e.key]; const dir = continuousKeys[e.key];
if (!_readerScrollInterval) { const now = performance.now();
_readerScrollInterval = setInterval(() => { if (_readerScrollDir !== dir) _readerScrollStartTs = now;
$('reader-content')?.scrollBy({top: _readerScrollStep}); _readerScrollDir = dir;
}, 16); _readerScrollLastTs = now;
if (!_readerScrollRaf) {
_readerScrollRaf = requestAnimationFrame(_readerScrollTick);
} }
} }
if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); } 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 => { document.addEventListener('keyup', e => {
if (e.key === 'j' || e.key === 'k') { if (e.key === 'j' || e.key === 'k') {
clearInterval(_readerScrollInterval); if (_readerScrollRaf) cancelAnimationFrame(_readerScrollRaf);
_readerScrollInterval = null; _readerScrollRaf = null;
_readerScrollDir = 0;
} }
}); });
@ -2309,9 +2312,19 @@ function formatDuration(seconds) {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { 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); 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, // 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). // but only if the reader isn't open (would interrupt reading).
@ -3176,8 +3189,22 @@ let pdfTotalPages = 0;
let _pdfPageTextBoxCache = {}; let _pdfPageTextBoxCache = {};
let _pdfRenderGen = 0; let _pdfRenderGen = 0;
let _touchStartX = 0; let _touchStartX = 0;
let _readerScrollInterval = null; let _readerScrollRaf = null;
let _readerScrollStep = 0; 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 _pinchStartDist = 0;
let _pinchStartZoom = 100; let _pinchStartZoom = 100;
let _isPinching = false; let _isPinching = false;

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-v32'; const CACHE = 'diora-v33';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',