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:
parent
976f009297
commit
c73b71d32f
10 changed files with 239 additions and 0 deletions
|
|
@ -3,5 +3,11 @@ FROM savonet/liquidsoap:v2.4.5
|
|||
COPY radio.liq /etc/liquidsoap/radio.liq
|
||||
COPY index.html /etc/liquidsoap/index.html
|
||||
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"]
|
||||
|
|
|
|||
BIN
stream/apple-touch-icon.png
Normal file
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
BIN
stream/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
stream/icon-512.png
Normal file
BIN
stream/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
stream/icon-maskable-512.png
Normal file
BIN
stream/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
49
stream/icon-maskable.svg
Normal file
49
stream/icon-maskable.svg
Normal 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 |
|
|
@ -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,
|
||||
|
|
|
|||
18
stream/manifest.webmanifest
Normal file
18
stream/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -224,6 +224,15 @@ output.harbor(
|
|||
home_html = file.contents("/etc/liquidsoap/index.html")
|
||||
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(
|
||||
port=8000, method="GET", "/",
|
||||
fun(_, resp) -> begin
|
||||
|
|
@ -240,6 +249,58 @@ harbor.http.register(
|
|||
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(
|
||||
port=8000, method="GET", "/nowplaying",
|
||||
fun(_, resp) -> begin
|
||||
|
|
|
|||
61
stream/sw.js
Normal file
61
stream/sw.js
Normal 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("/")))
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue