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.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)

View file

@ -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;

View file

@ -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',