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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
/* Nunito, self-hosted: one variable file per subset covers 500-900.
Google Fonts would be a network round trip the mouse may not have, and a
cached app shell rendered in the fallback face looks broken. latin-ext is
what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
Google also offers are dropped, since nothing in this UI can produce them. */
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

BIN
web/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
web/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,22 @@
{
"name": "Musik Delphin",
"short_name": "Musik",
"description": "Musik und Hörbücher für die MusicMouse",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d2f3c",
"theme_color": "#0d2f3c",
"lang": "de",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

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();
}),
);
});