Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

79
web/public/sw.js Normal file
View File

@@ -0,0 +1,79 @@
// A deliberately small service worker: it exists so iOS treats this as an installable
// app and so the shell survives a flaky wifi moment. It does NOT try to make the player
// work offline - the audio, the library and the state all live on the mouse, and a
// cached answer for any of those would be a lie.
const SHELL = "musikdelphin-shell-v1";
const COVERS = "musikdelphin-covers-v1";
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL)
.then((cache) =>
cache.addAll([
"/",
"/manifest.webmanifest",
"/dolphin-mascot.png",
"/fonts/nunito.css",
"/fonts/nunito-latin.woff2",
"/fonts/nunito-latin-ext.woff2",
]),
)
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Cover art is immutable per album id and is the only heavy thing here.
if (url.pathname.startsWith("/api/albums/")) {
event.respondWith(
caches.open(COVERS).then(async (cache) => {
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}),
);
return;
}
// Everything else under /api is live state. Never cache it, never serve it stale.
if (url.pathname.startsWith("/api/")) return;
// The shell: network first so a rebuilt frontend is picked up on the next load,
// falling back to the cache when the mouse is off or out of range.
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(async () => {
const hit = await caches.match(request);
// A client-side app: any in-scope navigation resolves to the one document.
return hit ?? (await caches.match("/")) ?? Response.error();
}),
);
});