From 9ee6dcf94a2aad9f8e967e2363f00036caa824cf Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 13:20:21 +0300 Subject: [PATCH] Add PWA support and mobile responsive layout - PWA manifest, service worker (stale-while-revalidate for static assets, network-only for API), and app icons for installability - Root-scoped /manifest.json and /sw.js routes in FastAPI - New mobile.css with responsive breakpoints at 768/600/400px: fixed bottom tab bar on phones, single-column cards, full-screen modals, compact header toolbar, touch-friendly targets - Fix modal-content-wide min-width overflow on small screens - Update README with Camera, OpenRGB, and PWA features Co-Authored-By: Claude Opus 4.6 --- README.md | 15 +- TODO.md | 4 +- server/src/wled_controller/main.py | 22 +- .../src/wled_controller/static/css/mobile.css | 550 ++++++++++++++++++ .../src/wled_controller/static/css/modal.css | 7 +- .../wled_controller/static/icons/icon-192.png | Bin 0 -> 1804 bytes .../static/icons/icon-512-maskable.png | Bin 0 -> 4391 bytes .../wled_controller/static/icons/icon-512.png | Bin 0 -> 5117 bytes .../src/wled_controller/static/manifest.json | 28 + server/src/wled_controller/static/sw.js | 90 +++ .../src/wled_controller/templates/index.html | 9 + 11 files changed, 715 insertions(+), 10 deletions(-) create mode 100644 server/src/wled_controller/static/css/mobile.css create mode 100644 server/src/wled_controller/static/icons/icon-192.png create mode 100644 server/src/wled_controller/static/icons/icon-512-maskable.png create mode 100644 server/src/wled_controller/static/icons/icon-512.png create mode 100644 server/src/wled_controller/static/manifest.json create mode 100644 server/src/wled_controller/static/sw.js diff --git a/README.md b/README.md index f83e540..44d2274 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A Home Assistant integration exposes devices as entities for smart home automati ### Screen Capture - Multi-monitor support with per-target display selection -- 5 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB) +- 6 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV) - Configurable capture regions, FPS, and border width - Capture templates for reusable configurations @@ -23,6 +23,7 @@ A Home Assistant integration exposes devices as entities for smart home automati - Adalight (serial) — Arduino-compatible LED controllers - AmbileD (serial) - DDP (Distributed Display Protocol, UDP) +- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips) - Serial port auto-detection and baud rate configuration ### Color Processing @@ -48,6 +49,8 @@ A Home Assistant integration exposes devices as entities for smart home automati ### Dashboard - Web UI at `http://localhost:8080` — no installation needed on the client side +- Progressive Web App (PWA) — installable on phones and tablets with offline caching +- Responsive mobile layout with bottom tab navigation - Device management with auto-discovery wizard - Visual calibration editor with overlay preview - Live LED strip preview via WebSocket @@ -72,6 +75,7 @@ A Home Assistant integration exposes devices as entities for smart home automati | Feature | Windows | Linux / macOS | | ------- | ------- | ------------- | | Screen capture | DXCam, BetterCam, WGC, MSS | MSS | +| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) | | Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) | | GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) | | Android capture | Scrcpy (ADB) | Scrcpy (ADB) | @@ -114,8 +118,8 @@ wled-screen-controller/ │ │ │ └── schemas/ # Pydantic request/response models │ │ ├── core/ │ │ │ ├── capture/ # Screen capture, calibration, pixel processing -│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy backends -│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP clients +│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends +│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients │ │ │ ├── audio/ # Audio capture engines │ │ │ ├── filters/ # Post-processing filter pipeline │ │ │ ├── processing/ # Stream orchestration and target processors @@ -214,10 +218,11 @@ black src/ tests/ ruff check src/ tests/ ``` -Optional high-performance capture engines (Windows only): +Optional extras: ```bash -pip install -e ".[perf]" +pip install -e ".[perf]" # High-performance capture engines (Windows) +pip install -e ".[camera]" # Webcam capture via OpenCV ``` ## License diff --git a/TODO.md b/TODO.md index a1826a6..3ed8420 100644 --- a/TODO.md +++ b/TODO.md @@ -58,6 +58,4 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag - Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration - Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming") -- [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest - - Complexity: medium-large — responsive CSS overhaul for all tabs; service worker for offline caching; manifest.json; touch-friendly controls (larger tap targets, swipe gestures) - - Impact: high — phone control is a top user request; current desktop-first layout is unusable on mobile +- [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 6b92913..f5a2834 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -6,7 +6,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.requests import Request @@ -248,6 +248,26 @@ app.add_middleware( # Include API routes app.include_router(router) +# PWA: serve manifest and service worker from root scope +_static_root = Path(__file__).parent / "static" + + +@app.get("/manifest.json", include_in_schema=False) +async def pwa_manifest(): + """Serve PWA manifest from root scope.""" + return FileResponse(_static_root / "manifest.json", media_type="application/manifest+json") + + +@app.get("/sw.js", include_in_schema=False) +async def pwa_service_worker(): + """Serve service worker from root scope (controls all pages).""" + return FileResponse( + _static_root / "sw.js", + media_type="application/javascript", + headers={"Cache-Control": "no-cache"}, + ) + + # Mount static files static_path = Path(__file__).parent / "static" if static_path.exists(): diff --git a/server/src/wled_controller/static/css/mobile.css b/server/src/wled_controller/static/css/mobile.css new file mode 100644 index 0000000..ed52c27 --- /dev/null +++ b/server/src/wled_controller/static/css/mobile.css @@ -0,0 +1,550 @@ +/* ── Mobile & Tablet Responsive Overrides ────────────────────── + Loaded last — overrides desktop-first styles from other CSS files. + Breakpoints: 768px (tablets), 600px (phones), 400px (small phones) + ─────────────────────────────────────────────────────────────── */ + +/* ================================================================ + TABLET (≤ 768px) + ================================================================ */ +@media (max-width: 768px) { + /* Header — keep single row, scroll toolbar if needed */ + header { + flex-direction: column; + gap: 4px; + padding: 4px 0 6px; + text-align: center; + } + + .header-toolbar { + justify-content: center; + } + + /* Container */ + .container { + padding: 10px; + } + + /* Cards grid — allow narrower cards on tablets */ + .displays-grid, + .devices-grid { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; + } + + /* Modals — near full width */ + .modal-content { + width: 95%; + max-width: none; + margin: 10px; + } + + .modal-content-wide { + min-width: 0; + width: 95%; + max-width: none; + } + + /* Modal padding reduction */ + .modal-header { + padding: 16px 16px 12px; + } + + .modal-header h2 { + font-size: 1.25rem; + } + + .modal-body { + padding: 16px; + } + + .modal-footer { + padding: 12px 16px 16px; + } + + /* Section headings */ + h2 { + font-size: 1.25rem; + margin-bottom: 14px; + } + + /* Segment range fields — allow wrapping */ + .segment-range-fields { + flex-wrap: wrap; + } + + .segment-range-fields input[type="number"] { + width: 60px; + } + + /* Composite layer editor */ + .composite-layer-blend { + width: 80px; + } + + /* Display picker */ + .display-picker-content { + width: 95%; + } +} + + +/* ================================================================ + PHONE (≤ 600px) + ================================================================ */ +@media (max-width: 600px) { + /* Prevent horizontal scroll */ + html, body { + overflow-x: hidden; + } + + /* ── Header ── */ + header { + padding: 4px 0 6px; + } + + .header-title { + gap: 6px; + } + + .header-toolbar { + gap: 1px; + padding: 2px 3px; + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .header-toolbar::-webkit-scrollbar { + display: none; + } + + .header-toolbar-sep { + display: none; + } + + .header-link { + display: none; + } + + .header-btn { + min-width: 32px; + min-height: 32px; + padding: 4px 6px; + flex-shrink: 0; + } + + .header-locale { + flex-shrink: 0; + width: auto; + max-width: 48px; + } + + h1 { + font-size: 1.1rem; + } + + #server-version { + display: none; + } + + /* ── Bottom Tab Bar ── */ + .tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + background: var(--card-bg); + border-bottom: none; + border-top: 1px solid var(--border-color); + margin-bottom: 0; + display: flex; + flex-wrap: nowrap; + justify-content: space-around; + padding: 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + box-shadow: 0 -2px 8px var(--shadow-color); + gap: 0; + } + + .tab-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 4px 6px; + font-size: 0.65rem; + border-bottom: none; + border-top: 2px solid transparent; + margin-bottom: 0; + position: relative; + } + + .tab-btn.active { + border-bottom-color: transparent; + border-top-color: var(--primary-color); + } + + .tab-btn .icon { + width: 20px; + height: 20px; + display: block; + } + + .tab-btn > span[data-i18n] { + font-size: 0.6rem; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* Tab badge repositioned to top-right of icon */ + .tab-badge { + position: absolute; + top: 2px; + right: calc(50% - 18px); + font-size: 0.55rem; + padding: 0 4px; + min-width: 14px; + line-height: 1.2; + margin-left: 0; + } + + /* Body padding for fixed bottom bar */ + body { + padding-bottom: 64px; + } + + /* ── Container ── */ + .container { + padding: 8px; + } + + /* ── Cards — single column ── */ + .displays-grid, + .devices-grid, + .templates-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .card { + padding: 10px 14px 14px; + } + + .card-title { + font-size: 1.05rem; + } + + .card-header { + padding-right: 24px; + } + + .add-device-card { + min-height: 100px; + } + + .add-device-icon { + font-size: 2rem; + } + + /* ── Modals — full screen ── */ + .modal-content { + width: 100%; + max-width: 100%; + max-height: 100%; + border-radius: 0; + margin: 0; + } + + .modal-content-wide { + min-width: 0; + width: 100%; + max-width: 100%; + border-radius: 0; + } + + .modal-header { + padding: 12px 14px 10px; + } + + .modal-header h2 { + font-size: 1.15rem; + } + + .modal-body { + padding: 14px; + } + + .modal-footer { + padding: 10px 14px 14px; + } + + /* Inline fields stack vertically */ + .inline-fields { + flex-direction: column; + gap: 8px; + } + + /* Segment rows — stack vertically */ + .segment-row-fields { + flex-direction: column; + align-items: stretch; + } + + .segment-range-fields { + flex-wrap: wrap; + } + + .segment-range-fields input[type="number"] { + width: 100%; + flex: 1; + } + + /* Buttons */ + .btn { + min-width: 0; + } + + .modal-footer .btn-icon { + min-width: 50px; + padding: 8px 16px; + } + + /* Form groups */ + .form-group { + margin-bottom: 12px; + } + + /* Gradient stop rows — tighter */ + .gradient-stop-row { + gap: 4px; + padding: 4px 6px; + } + + .gradient-stop-pos { + width: 60px; + max-width: 60px; + } + + /* Composite layers */ + .composite-layer-row { + flex-wrap: wrap; + } + + .composite-layer-blend { + width: 100%; + } + + /* Metrics grid — single column */ + .metrics-grid { + grid-template-columns: 1fr; + } + + /* Timing legend */ + .timing-legend { + gap: 4px; + font-size: 0.7rem; + } + + /* Audio test stats */ + .audio-test-stats, + .vs-test-stats { + flex-wrap: wrap; + gap: 10px; + } + + /* Section */ + section { + margin-bottom: 24px; + } + + h2 { + font-size: 1.15rem; + margin-bottom: 10px; + } + + /* Section tip */ + .section-tip { + font-size: 0.78rem; + padding: 6px 10px; + } + + /* Card subtitle gap */ + .card-subtitle { + gap: 8px; + margin-bottom: 10px; + } + + /* Footer */ + .app-footer { + margin-bottom: 50px; + } + + /* Command palette */ + #command-palette { + padding-top: 5vh; + } + + .cp-dialog { + width: 95vw; + } + + /* Stream sub-tabs */ + .stream-tab-bar { + flex-wrap: wrap; + gap: 2px; + margin-bottom: 10px; + } + + .stream-tab-btn { + padding: 6px 8px; + font-size: 0.8rem; + } + + .stream-tab-count { + font-size: 0.6rem; + padding: 0 4px; + } + + .cs-expand-collapse-group { + gap: 1px; + } + + .btn-expand-collapse { + width: 26px; + height: 26px; + font-size: 0.75rem; + } + + /* Display picker */ + .display-picker-content { + width: 98%; + } + + .display-picker-canvas { + padding: 12px; + } + + .display-picker-title { + font-size: 1.1rem; + margin-bottom: 12px; + } + + /* Lightbox */ + .lightbox-stats { + font-size: 0.7rem; + gap: 10px; + padding: 6px 10px; + } +} + + +/* ================================================================ + SMALL PHONE (≤ 400px) + ================================================================ */ +@media (max-width: 400px) { + /* Tighter header */ + h1 { + font-size: 1rem; + } + + /* Cards */ + .card { + padding: 8px 10px 12px; + } + + .card-title { + font-size: 0.95rem; + } + + .card-top-actions { + gap: 1px; + } + + .card-remove-btn, + .card-power-btn, + .card-autostart-btn { + width: 32px; + height: 32px; + } + + /* Tab buttons even tighter */ + .tab-btn { + padding: 6px 2px 4px; + } + + .tab-btn > span[data-i18n] { + font-size: 0.55rem; + } + + /* Modal body */ + .modal-body { + padding: 10px; + } + + .modal-header { + padding: 10px 12px 8px; + } +} + + +/* ================================================================ + TOUCH DEVICE ENHANCEMENTS + ================================================================ */ +@media (hover: none) and (pointer: coarse) { + /* Larger touch targets */ + .card-remove-btn, + .card-power-btn, + .card-autostart-btn { + width: 34px; + height: 34px; + } + + .modal-close-btn { + width: 38px; + height: 38px; + } + + .hint-toggle { + width: 24px; + height: 24px; + } + + /* Always show drag handles on touch */ + .card > .card-drag-handle, + .template-card > .card-drag-handle { + opacity: 0.4; + } + + /* Disable hover transform on cards (causes janky scrolling) */ + .card:hover { + transform: none; + } + + .add-device-card:hover { + transform: none; + } + + /* Color picker dots — larger for fingers */ + .color-picker-dot { + width: 38px; + height: 38px; + } +} + + +/* ================================================================ + STANDALONE PWA MODE + ================================================================ */ +@media (display-mode: standalone) { + /* In standalone/PWA mode the browser chrome is gone, + so we can use full viewport safely */ + header { + padding-top: env(safe-area-inset-top, 4px); + } +} diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index f132f76..ff45186 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -545,13 +545,18 @@ .modal-content-wide { width: fit-content; - min-width: 500px; max-width: calc(100vw - 40px); max-height: calc(100vh - 40px); display: flex; flex-direction: column; } +@media (min-width: 769px) { + .modal-content-wide { + min-width: 500px; + } +} + .modal-content-wide .modal-body { overflow-y: auto; scrollbar-gutter: stable; diff --git a/server/src/wled_controller/static/icons/icon-192.png b/server/src/wled_controller/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..1a3eb3a6d732057d602c91f344dab10eb90a99b1 GIT binary patch literal 1804 zcmb7_YdF*i8^*o={!=p}a#p!$Ffk5UJCrqAbSs?fyRi7*Hwc9>}#rj3T= z5ShlgtR3g5kyj42CJHMJ%3&*GYKsOlOgrzkAKu;TeLw7n=XpLnU+(LAe$Rbp5eaT8 z@ZE4FB_$OPcNZVU?EX(d0Y#s>CnG8;ZGGzDa@a4iRDAuXJmC>F*H?bFR$FxJz}>dv z!H9757H!Sk3Qon93NYZ|_l5&RsI4HkLMx!~elhxp5csJxV0&_yboB*RRF~LhHmG}R0x$;wTvi<%b!p;QqVV+gVDH0PecE4f%O~ns)N}a0jq22_3j5cs2Yqfhpe?mSW%Lc zW(6G`0JERJg`^+xwx(V53&($T$-!-6-Py)s*L0apWx>@1o>3IAQ2dMky**U4NVHE( zI*|eve@$)spwLEbGHt5*jYDYdcDwOx8&=nRrW)g`gRvHsJn9d}&OB@nN(-k>rzW?_ zOFnIi%M&a*=07wC-Y=V%pPX>g#Z35;en#u36l0K~Ua`8U6CzjNcyl}@rwV=jBmpBq zLTFDI&X-hi+v2NMYI`>MDtT(CmbjWGyTa!B2UBHSW8!x&ayv1;b%i&By`HF}%2$6* zZti&Fv*=lS+&fjDao(XB#D_6SV@08siyL!rz+=O{e}zY$6dH;>7DQcRnvBj{a#&o8 z@;S%57Ch5zj18XLCEdJdKW|Aaz-o<_c@~gu-Ixw;J0)Da=zQs!G8oD7;2n$&%X?ni zDGEl%+OV{f7`c~m6O@8XReLBMt4yc3&mJ01?rSyGtnZr!zG-#8B?xTvo3y9*Acs?z z23=~~>TxA@ZuwE;XDDM>=bCaH{G4!!88xM9-6PrMxNCFrAguSdN!A*IO+YLSqE;)I zxYEtUk&5LzFm9JvYW!Tqn?{l?WQKVaEB9`EsEq%lt?5pwH$zMmwF2sna9b=q7KUse zLa6F<->6|es0S~gBC-vo%+ z63Eu+XDG1L0J2!i(WsSUPKZq9&!m2Y%xZWRExPqP(zFEN-0U3p0pqWQ)2T`6GZkh43= zd-L_0sTzIj=U;%)gpa`t&afSfhOjyY&N_MisnHxTV=V_{#(BCTpm+<~0aj4nKJ9ds zyWhoy;y+7PfrMu7cM4D4s{v&Vz+9mFZjP6?0x?GeX;p5At!P)KO`aZZv>AdJ(NiL( z(jI{LD?vm0LuHxpd_UO&TNM@JI|<>>2J~veY7-Gf%+)X8Q|G-Rz`oQd?KiZhiIUhwlPD; zUL$g2a(Wlfne{D7Pt~p=)l^UHEyWJT&|eO-G9}!6165|mqb~>ra<_<8(}riYyj@-3 z2ZyW%AIb^}9_ba!hj$)N@^0vjulaVNEqRujGeerN6zPQRS2 zFe1iN1?}X~1|0|!3D%Jx7drAMu0FlG!!fOKqrKQ!ZaQK^W$CN8nHKqfCGvV>e71hh za`-rv@I`fGHB%}fwccJ9>X%~O7fpB_cRa_~A%?kM>ZxVJ z%!t2^Ljrs28*fOs!$75;x0cAx0h~!EG{0UXZOnMycD*&HPHs_V<>JI^o6q;G z=bW0^nC;Z~rq2Tr9nuZfC232l*57iZ}cp5%7Pr;U6q!9bqro V%>!m#DT3=z!!bQV#PdvXkiDohZzdHj1L; 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 literal 0 HcmV?d00001 diff --git a/server/src/wled_controller/static/icons/icon-512.png b/server/src/wled_controller/static/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..af5b68eef116a672003fad0ccddfc53df9a67994 GIT binary patch literal 5117 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 aU)ag~GUX6u3mbXX17`b4CV6{ZqyGVv3AN1t literal 0 HcmV?d00001 diff --git a/server/src/wled_controller/static/manifest.json b/server/src/wled_controller/static/manifest.json new file mode 100644 index 0000000..6a8a3b5 --- /dev/null +++ b/server/src/wled_controller/static/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "LED Grab", + "short_name": "LED Grab", + "description": "WLED ambient lighting controller based on screen content", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#4CAF50", + "orientation": "any", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js new file mode 100644 index 0000000..1e8643d --- /dev/null +++ b/server/src/wled_controller/static/sw.js @@ -0,0 +1,90 @@ +/** + * Service Worker for LED Grab PWA. + * + * Strategy: + * - Static assets (/static/): stale-while-revalidate + * - API / config requests: network-only (device control must be live) + * - Navigation: network-first with offline fallback + */ + +const CACHE_NAME = 'ledgrab-v1'; + +const PRECACHE_URLS = [ + '/', + '/static/css/base.css', + '/static/css/layout.css', + '/static/css/components.css', + '/static/css/cards.css', + '/static/css/modal.css', + '/static/css/calibration.css', + '/static/css/dashboard.css', + '/static/css/streams.css', + '/static/css/patterns.css', + '/static/css/automations.css', + '/static/css/tutorials.css', + '/static/css/mobile.css', + '/static/icons/icon-192.png', + '/static/icons/icon-512.png', +]; + +// Install: pre-cache core shell +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate: clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +// Fetch handler +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // API and config: always network (device control must be live) + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/config/')) { + return; // fall through to default network fetch + } + + // Static assets: stale-while-revalidate + if (url.pathname.startsWith('/static/')) { + event.respondWith( + caches.open(CACHE_NAME).then((cache) => + cache.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request).then((response) => { + if (response.ok) { + cache.put(event.request, response.clone()); + } + return response; + }).catch(() => cached); + + return cached || fetchPromise; + }) + ) + ); + return; + } + + // Navigation: network-first + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).catch(() => + caches.match('/') || new Response('Offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }) + ) + ); + return; + } +}); diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index bc3ef68..65a5d0b 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -5,6 +5,13 @@ LED Grab + + + + + + + @@ -16,6 +23,7 @@ + @@ -409,5 +417,6 @@ startAutoRefresh(); } +