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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:20:21 +03:00
parent 8fe9c6489b
commit 9ee6dcf94a
11 changed files with 715 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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);
}
}

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -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"
}
]
}

View File

@@ -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;
}
});

View File

@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<!-- PWA -->
<meta name="theme-color" content="#1a1a1a">
<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="LED Grab">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/layout.css">
<link rel="stylesheet" href="/static/css/components.css">
@@ -16,6 +23,7 @@
<link rel="stylesheet" href="/static/css/patterns.css">
<link rel="stylesheet" href="/static/css/automations.css">
<link rel="stylesheet" href="/static/css/tutorials.css">
<link rel="stylesheet" href="/static/css/mobile.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head>
<body style="visibility: hidden;">
@@ -409,5 +417,6 @@
startAutoRefresh();
}
</script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</body>
</html>