stream: make the web player installable as a PWA

Wire up a web manifest, service worker and icon routes so the player can
be installed on mobile. The static manifest stays generic; the page
regenerates it at runtime from STATION_NAME so an instance keeps its name.
The service worker only caches the app shell, never the live stream or the
playback APIs. Icons (192/512, maskable and apple-touch) are rasterized
from the favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-04 16:09:08 +08:00
commit c73b71d32f
10 changed files with 239 additions and 0 deletions

View file

@ -5,6 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title id="pageTitle"></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Application installable (PWA). Le manifeste décrit nom, icônes et mode
d'affichage ; les balises Apple font l'équivalent sur iOS, qui n'exploite
pas le manifeste pour l'ajout à l'écran d'accueil. La couleur de thème
teinte la barre système en mode autonome. -->
<link rel="manifest" href="/manifest.webmanifest" id="manifestLink">
<meta name="theme-color" content="#0d0b14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="" id="appleTitle">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
@ -235,6 +246,39 @@
document.getElementById("stationName").textContent = "◈ " + STATION_NAME;
document.getElementById("pageTitle").textContent = STATION_NAME;
// Le manifeste statique reste générique (« Radieo ») pour être réutilisable ;
// on le régénère ici à partir de STATION_NAME, l'unique réglage de la station,
// afin que l'app installée porte le nom de l'instance. Le manifeste est servi
// en data URL, donc toutes les URLs qu'il contient doivent être absolues.
(async () => {
try {
const link = document.getElementById("manifestLink");
const base = await fetch(link.href, { cache: "no-store" }).then((r) => r.json());
const abs = (p) => new URL(p, location.origin).href;
const manifest = {
...base,
name: STATION_NAME,
short_name: STATION_NAME,
start_url: abs(base.start_url || "/"),
scope: abs(base.scope || "/"),
icons: (base.icons || []).map((ic) => ({ ...ic, src: abs(ic.src) })),
};
const blob = new Blob([JSON.stringify(manifest)], { type: "application/manifest+json" });
link.href = URL.createObjectURL(blob);
} catch (e) { /* on garde le manifeste statique générique */ }
// iOS n'exploite pas le manifeste : on lui donne le nom via sa balise dédiée.
document.getElementById("appleTitle").setAttribute("content", STATION_NAME);
})();
// Enregistrement du service worker : condition pour que la webapp soit
// « installable » sur mobile (Android/Chrome) et pour ouvrir la fenêtre
// autonome depuis le cache. Échec silencieux si non supporté.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
// Fond aléatoire repris de l'écran de connexion Navidrome : on récupère la
// liste de leur galerie (index.yml, CORS ouvert), on tire un nom au hasard
// et on sert la version .webp depuis leur CDN — même logique que Navidrome,