Skip to content

Commit bd07a7c

Browse files
fhirschmannclaude
andcommitted
feat: PWA offline fallback page instead of black screen
When the home-screen PWA is launched while the ESPuino is powered off, page loads fail and iOS shows a black screen. A service worker now precaches a self-contained cyberpunk offline page and serves it for failed navigations; the page silently probes the device and reloads the app once it is back online. sw.js and offline.html are exempt from the password middleware so the worker can install and the offline page stays reachable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a10d161 commit bd07a7c

6 files changed

Lines changed: 232 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ neon logo that doubles as the SVG favicon ([`8d9725c`](../../commit/8d9725c)):
6565
| Change | Commit |
6666
| --- | --- |
6767
| PWA support: web app manifest + app icon, "add to home screen" with proper icon and name | [`a59fe0b`](../../commit/a59fe0b) |
68+
| PWA offline fallback: a service worker serves a cyberpunk "ESPuino Offline" page (with auto-reconnect) instead of a black screen when the home-screen app is launched while the player is powered off | [`ceb1c23`](../../commit/ceb1c23) |
6869
| Full backup: export/import of all settings + RFID assignments as JSON, WiFi credentials optional | [`7f2e4ae`](../../commit/7f2e4ae) |
6970
| One-click OTA update: GitHub Actions publishes a rolling `latest` release (`firmware.bin`); a Tools-tab button makes the device pull that firmware from GitHub over HTTPS and flash it via OTA, then reboot. Also triggerable via the bindable command **186** (button/RFID modifier) and the MQTT command-topic `firmware_update` (`ON`/`update`); the state-topic reports `idle`/`updating`/`up_to_date`/`failed` | [`06ac13d`](../../commit/06ac13d) |
7071
| Equalizer profiles: dropdown presets (Flat / Music / Audiobook-Speech / Deep voices / Custom) on top of the 3-band tone control; speech presets cut bass and lift mids/highs so deep narrator voices stay intelligible, persisted in NVS. Profiles can also be assigned per file or directory (right-click in the file browser) — e.g. set the speech profile for all Bibi Blocksberg episodes at once; the RFID tab shows the active profile for the highlighted file (or "No EQ set"). The active profile can be cycled with the bindable command **154** (button/RFID modifier) and set/reported via the MQTT topic `equalizer` (`flat`/`music`/`speech`/`voiceBoost`) | [`300b9dd`](../../commit/300b9dd) |

‎html/management.html‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
<meta name="viewport" content="width=device-width, initial-scale=1">
1313
<meta name="theme-color" content="#05070d">
1414
<link rel="manifest" href="/manifest.json">
15+
<script>
16+
// Register the offline service worker so the home-screen PWA shows an
17+
// offline page instead of a black screen when the player is powered off.
18+
if ('serviceWorker' in navigator) {
19+
window.addEventListener('load', function () {
20+
navigator.serviceWorker.register('/sw.js').catch(function () { });
21+
});
22+
}
23+
</script>
1524
<meta name="mobile-web-app-capable" content="yes">
1625
<meta name="apple-mobile-web-app-capable" content="yes">
1726
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

‎html/offline.html‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
7+
<meta name="theme-color" content="#05070d">
8+
<meta name="apple-mobile-web-app-capable" content="yes">
9+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10+
<title>LEOINO // OFFLINE</title>
11+
<style>
12+
:root {
13+
--cp-bg: #05070d;
14+
--cp-cyan: #00f0ff;
15+
--cp-magenta: #ff2a6d;
16+
--cp-dim: #6b7a99;
17+
}
18+
19+
* {
20+
box-sizing: border-box;
21+
}
22+
23+
html,
24+
body {
25+
margin: 0;
26+
height: 100%;
27+
}
28+
29+
body {
30+
background: radial-gradient(circle at 50% 30%, #0b1022 0%, var(--cp-bg) 70%);
31+
color: #cfe9ff;
32+
font-family: "Rajdhani", "Segoe UI", system-ui, -apple-system, sans-serif;
33+
display: flex;
34+
flex-direction: column;
35+
align-items: center;
36+
justify-content: center;
37+
text-align: center;
38+
padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom));
39+
min-height: 100vh;
40+
overflow: hidden;
41+
}
42+
43+
.glyph {
44+
width: 96px;
45+
height: 96px;
46+
margin-bottom: 28px;
47+
color: var(--cp-magenta);
48+
filter: drop-shadow(0 0 14px rgba(255, 42, 109, .55));
49+
animation: pulse 2.2s ease-in-out infinite;
50+
}
51+
52+
@keyframes pulse {
53+
54+
0%,
55+
100% {
56+
opacity: .55;
57+
transform: scale(1);
58+
}
59+
60+
50% {
61+
opacity: 1;
62+
transform: scale(1.04);
63+
}
64+
}
65+
66+
h1 {
67+
font-family: "Orbitron", "Rajdhani", sans-serif;
68+
letter-spacing: .18em;
69+
text-transform: uppercase;
70+
font-size: 1.5rem;
71+
margin: 0 0 10px;
72+
color: var(--cp-cyan);
73+
text-shadow: 0 0 12px rgba(0, 240, 255, .6);
74+
}
75+
76+
p {
77+
max-width: 360px;
78+
line-height: 1.5;
79+
color: var(--cp-dim);
80+
margin: 0 0 28px;
81+
font-size: 1rem;
82+
}
83+
84+
.status {
85+
font-family: "Share Tech Mono", monospace;
86+
letter-spacing: .15em;
87+
text-transform: uppercase;
88+
font-size: .8rem;
89+
color: var(--cp-magenta);
90+
margin-bottom: 6px;
91+
}
92+
93+
button {
94+
font-family: "Rajdhani", sans-serif;
95+
font-weight: 600;
96+
text-transform: uppercase;
97+
letter-spacing: .12em;
98+
font-size: 1rem;
99+
color: var(--cp-cyan);
100+
background: rgba(0, 240, 255, .06);
101+
border: 1px solid rgba(0, 240, 255, .5);
102+
border-radius: 6px;
103+
padding: 12px 28px;
104+
cursor: pointer;
105+
transition: all .25s ease;
106+
min-width: 200px;
107+
}
108+
109+
button:hover,
110+
button:active {
111+
color: #021019;
112+
background: var(--cp-cyan);
113+
box-shadow: 0 0 18px rgba(0, 240, 255, .6);
114+
}
115+
116+
button:disabled {
117+
opacity: .5;
118+
pointer-events: none;
119+
}
120+
121+
.foot {
122+
position: fixed;
123+
bottom: max(14px, env(safe-area-inset-bottom));
124+
left: 0;
125+
right: 0;
126+
font-family: "Share Tech Mono", monospace;
127+
font-size: .7rem;
128+
letter-spacing: .12em;
129+
color: rgba(107, 122, 153, .7);
130+
}
131+
</style>
132+
</head>
133+
134+
<body>
135+
<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
136+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
137+
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
138+
<line x1="12" y1="2" x2="12" y2="12" />
139+
</svg>
140+
<div class="status" id="status">// signal lost</div>
141+
<h1>ESPuino Offline</h1>
142+
<p>The player is powered down or out of reach. This screen stays available so the app never goes dark. It will
143+
reconnect automatically once the device is back online.</p>
144+
<button id="retry" type="button">Reconnect</button>
145+
<div class="foot">LEO INDUSTRIES // AT-1 &nbsp;·&nbsp; NEON DREAMS RUN ON ESP32</div>
146+
147+
<script>
148+
(function () {
149+
var btn = document.getElementById('retry');
150+
var status = document.getElementById('status');
151+
152+
// Probe a small, auth-exempt endpoint. On success the device is back -> load the app.
153+
function probe() {
154+
return fetch('/manifest.json', { method: 'GET', cache: 'no-store' })
155+
.then(function (r) { return r && r.ok; })
156+
.catch(function () { return false; });
157+
}
158+
159+
function attempt(manual) {
160+
if (manual) {
161+
btn.disabled = true;
162+
status.textContent = '// reconnecting…';
163+
}
164+
probe().then(function (online) {
165+
if (online) {
166+
status.textContent = '// link restored';
167+
location.replace('/');
168+
} else if (manual) {
169+
btn.disabled = false;
170+
status.textContent = '// still offline';
171+
}
172+
});
173+
}
174+
175+
btn.addEventListener('click', function () { attempt(true); });
176+
// silent auto-retry every 5s
177+
setInterval(function () { attempt(false); }, 5000);
178+
// also retry as soon as the OS reports connectivity is back
179+
window.addEventListener('online', function () { attempt(false); });
180+
})();
181+
</script>
182+
</body>
183+
184+
</html>

‎html/sw.js‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Service worker for the ESPuino PWA.
2+
// Purpose: when the player is powered off and the app is launched from the
3+
// home screen, page loads fail and iOS shows a black screen. This worker
4+
// serves a cached offline page for failed navigations instead.
5+
6+
const CACHE = 'espuino-offline-v1';
7+
const OFFLINE_URL = '/offline.html';
8+
9+
self.addEventListener('install', (event) => {
10+
event.waitUntil(
11+
caches.open(CACHE).then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' })))
12+
);
13+
self.skipWaiting();
14+
});
15+
16+
self.addEventListener('activate', (event) => {
17+
event.waitUntil(
18+
caches.keys().then((keys) => Promise.all(
19+
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
20+
)).then(() => self.clients.claim())
21+
);
22+
});
23+
24+
self.addEventListener('fetch', (event) => {
25+
const req = event.request;
26+
// Only intervene for top-level page navigations. Everything else (assets,
27+
// websocket upgrades, API calls) is left to the network as usual.
28+
if (req.mode !== 'navigate') {
29+
return;
30+
}
31+
event.respondWith(
32+
fetch(req).catch(() => caches.match(OFFLINE_URL, { ignoreSearch: true }))
33+
);
34+
});

‎processHtml.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
Path("accesspoint.html"),
3131
Path("manifest.json"),
3232
Path("appicon.svg"),
33+
Path("sw.js"),
34+
Path("offline.html"),
3335
Path("logo.svg"),
3436
Path("js/i18next.min.js"),
3537
Path("js/i18nextHttpBackend.min.js"),

‎src/Web.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,8 +645,9 @@ void webserverStart(void) {
645645
return next();
646646
}
647647
const String &url = request->url();
648-
if (url.equals("/login") || url.equals("/logo") || url.equals("/logo.svg") || url.equals("/favicon.ico") || url.equals("/manifest.json") || url.equals("/appicon.svg")) {
648+
if (url.equals("/login") || url.equals("/logo") || url.equals("/logo.svg") || url.equals("/favicon.ico") || url.equals("/manifest.json") || url.equals("/appicon.svg") || url.equals("/sw.js") || url.equals("/offline.html")) {
649649
// manifest/icon: the browser fetches the web-app-manifest without cookies
650+
// sw.js/offline.html: needed by the PWA so a powered-off device shows an offline page instead of a black screen
650651
return next();
651652
}
652653
if (Web_IsAuthenticated(request)) {

0 commit comments

Comments
 (0)