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 0000000..1a3eb3a Binary files /dev/null and b/server/src/wled_controller/static/icons/icon-192.png differ diff --git a/server/src/wled_controller/static/icons/icon-512-maskable.png b/server/src/wled_controller/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..433d3da Binary files /dev/null and b/server/src/wled_controller/static/icons/icon-512-maskable.png differ 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 0000000..af5b68e Binary files /dev/null and b/server/src/wled_controller/static/icons/icon-512.png differ 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(); } +