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

@ -3,5 +3,11 @@ FROM savonet/liquidsoap:v2.4.5
COPY radio.liq /etc/liquidsoap/radio.liq COPY radio.liq /etc/liquidsoap/radio.liq
COPY index.html /etc/liquidsoap/index.html COPY index.html /etc/liquidsoap/index.html
COPY favicon.svg /etc/liquidsoap/favicon.svg COPY favicon.svg /etc/liquidsoap/favicon.svg
COPY manifest.webmanifest /etc/liquidsoap/manifest.webmanifest
COPY sw.js /etc/liquidsoap/sw.js
COPY icon-192.png /etc/liquidsoap/icon-192.png
COPY icon-512.png /etc/liquidsoap/icon-512.png
COPY icon-maskable-512.png /etc/liquidsoap/icon-maskable-512.png
COPY apple-touch-icon.png /etc/liquidsoap/apple-touch-icon.png
CMD ["/etc/liquidsoap/radio.liq"] CMD ["/etc/liquidsoap/radio.liq"]

BIN
stream/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
stream/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
stream/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

49
stream/icon-maskable.svg Normal file
View file

@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Variante « full-bleed » du favicon, destinée aux icônes d'application
(PWA maskable + apple-touch-icon). Le fond couvre tout le carré (pas de
coins arrondis : Android/iOS appliquent leur propre masque) et le motif
est ramené dans la zone de sécurité centrale (~80%) pour ne pas être
rogné par un masque circulaire. -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1a1030"/>
<stop offset="1" stop-color="#3a1145"/>
</linearGradient>
<linearGradient id="sun" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffe15a"/>
<stop offset="0.5" stop-color="#ff5c8a"/>
<stop offset="1" stop-color="#9b4dff"/>
</linearGradient>
<linearGradient id="grid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9b8cff" stop-opacity="0"/>
<stop offset="1" stop-color="#ff4fd8" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="64" height="64" fill="url(#sky)"/>
<!-- Motif ramené dans la zone de sécurité : translation + échelle 0.72. -->
<g transform="translate(9 9) scale(0.72)" clip-path="none">
<g>
<circle cx="32" cy="27" r="16" fill="url(#sun)"/>
<g fill="#1a1030">
<rect x="14" y="30" width="36" height="1.6"/>
<rect x="14" y="33.5" width="36" height="2.2"/>
<rect x="14" y="37.5" width="36" height="3"/>
<rect x="14" y="42" width="36" height="4"/>
</g>
</g>
<rect x="0" y="45" width="64" height="1.4" fill="#ff4fd8"/>
<g stroke="url(#grid)" stroke-width="1">
<line x1="32" y1="46" x2="-8" y2="66"/>
<line x1="32" y1="46" x2="8" y2="66"/>
<line x1="32" y1="46" x2="24" y2="66"/>
<line x1="32" y1="46" x2="40" y2="66"/>
<line x1="32" y1="46" x2="56" y2="66"/>
<line x1="32" y1="46" x2="72" y2="66"/>
<line x1="0" y1="50" x2="64" y2="50"/>
<line x1="0" y1="55" x2="64" y2="55"/>
<line x1="0" y1="61" x2="64" y2="61"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -5,6 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title id="pageTitle"></title> <title id="pageTitle"></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <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> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -235,6 +246,39 @@
document.getElementById("stationName").textContent = "◈ " + STATION_NAME; document.getElementById("stationName").textContent = "◈ " + STATION_NAME;
document.getElementById("pageTitle").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 // 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 // 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, // et on sert la version .webp depuis leur CDN — même logique que Navidrome,

View file

@ -0,0 +1,18 @@
{
"name": "Radieo",
"short_name": "Radieo",
"description": "Radio synthwave en direct.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0d0b14",
"theme_color": "#0d0b14",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View file

@ -224,6 +224,15 @@ output.harbor(
home_html = file.contents("/etc/liquidsoap/index.html") home_html = file.contents("/etc/liquidsoap/index.html")
favicon_svg = file.contents("/etc/liquidsoap/favicon.svg") favicon_svg = file.contents("/etc/liquidsoap/favicon.svg")
# Ressources de la webapp installable (PWA) : manifeste, service worker et jeu
# d'icônes. Chargées une fois au démarrage puis servies telles quelles.
manifest_json = file.contents("/etc/liquidsoap/manifest.webmanifest")
sw_js = file.contents("/etc/liquidsoap/sw.js")
icon_192 = file.contents("/etc/liquidsoap/icon-192.png")
icon_512 = file.contents("/etc/liquidsoap/icon-512.png")
icon_maskable_512 = file.contents("/etc/liquidsoap/icon-maskable-512.png")
apple_touch_icon = file.contents("/etc/liquidsoap/apple-touch-icon.png")
harbor.http.register( harbor.http.register(
port=8000, method="GET", "/", port=8000, method="GET", "/",
fun(_, resp) -> begin fun(_, resp) -> begin
@ -240,6 +249,58 @@ harbor.http.register(
end end
) )
harbor.http.register(
port=8000, method="GET", "/manifest.webmanifest",
fun(_, resp) -> begin
resp.content_type("application/manifest+json; charset=utf-8")
resp.data(manifest_json)
end
)
# Le service worker doit être servi depuis la racine pour couvrir tout le site
# (portée par défaut = son emplacement). Pas de cache HTTP agressif : le
# navigateur revérifie régulièrement le script.
harbor.http.register(
port=8000, method="GET", "/sw.js",
fun(_, resp) -> begin
resp.content_type("text/javascript; charset=utf-8")
resp.header("Cache-Control", "no-cache")
resp.data(sw_js)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-192.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_192)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_512)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-maskable-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_maskable_512)
end
)
harbor.http.register(
port=8000, method="GET", "/apple-touch-icon.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(apple_touch_icon)
end
)
harbor.http.register( harbor.http.register(
port=8000, method="GET", "/nowplaying", port=8000, method="GET", "/nowplaying",
fun(_, resp) -> begin fun(_, resp) -> begin

61
stream/sw.js Normal file
View file

@ -0,0 +1,61 @@
// Service worker minimal : son seul rôle indispensable est de rendre la webapp
// « installable » (critère PWA d'Android/Chrome) en présentant un gestionnaire
// de requêtes. Accessoirement, il met en cache la coquille de l'application
// (page, icônes, manifest) pour que la fenêtre autonome s'ouvre instantanément,
// même hors ligne.
//
// Rien de ce qui est « vivant » ne passe par le cache : le flux audio
// (/radio.mp3) et toutes les API de lecture (/nowplaying, /history, /queue,
// /skip…) vont toujours directement au réseau. On se contente donc de gérer les
// quelques ressources statiques de la coquille.
const CACHE = "nemufm-shell-v1";
const SHELL = [
"/",
"/manifest.webmanifest",
"/favicon.svg",
"/icon-192.png",
"/icon-512.png",
"/icon-maskable-512.png",
"/apple-touch-icon.png",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (e) => {
// Purge des anciennes versions de coquille.
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (e) => {
const req = e.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
// Hors périmètre (CDN d'arrière-plan…) ou ressources vivantes : on laisse le
// navigateur faire, sans interception ni cache.
if (url.origin !== location.origin) return;
const shellPaths = new Set(SHELL);
const isShell = url.pathname === "/" ? true : shellPaths.has(url.pathname);
if (!isShell) return; // flux et API : réseau direct, pas de cache.
// Coquille : réseau d'abord (pour récupérer une version fraîche), repli sur le
// cache si le réseau manque, avec mise à jour du cache au passage.
e.respondWith(
fetch(req)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
return resp;
})
.catch(() => caches.match(req).then((r) => r || caches.match("/")))
);
});