Compare commits

...

16 Commits

Author SHA1 Message Date
alexei.dolgolyov 5ef6ac1317 chore: release v0.6.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 3m52s
Build Release / build-linux (push) Successful in 5m20s
Build Release / build-docker (push) Successful in 6m20s
Build Release / build-windows (push) Successful in 7m7s
2026-05-01 19:11:15 +03:00
alexei.dolgolyov 0980cf4dde fix(ui): audio-source modal — preserve device on refresh, relocate refresh action
- Move the device refresh button into the label row next to "Audio Device:"
  so it can no longer overflow the Source panel edge; introduces a small
  .label-row-action style alongside .hint-toggle.
- Restore device selection after refresh by matching on (index, loopback)
  value first, with a trimmed name fallback for OS-side reindexing.
- _selectAudioDevice now syncs the EntitySelect trigger so the visible
  label matches the underlying <select> when the modal opens in edit mode.
- Drop unused min-width/overflow on .transport-status.
2026-05-01 19:04:36 +03:00
alexei.dolgolyov fdac26b9d9 feat: daylight tz, camera engine, value stream + modal/UI polish
- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior
- camera engine: capture path additions plus new test_camera_engine suite
- value stream: schema + processing updates (~178 lines)
- color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path
- modal CSS: large refactor (+883), components.css polish (+110)
- templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox
- frontend core: state, modal, icons, graph-nodes, app
- frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards
- locales: en/ru/zh
- storage: color_strip, picture_source, value_source loaders touched
- preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
2026-05-01 18:42:43 +03:00
alexei.dolgolyov 816a27db73 refactor(ui): drop app footer, move author info to About panel
The "Created by Alexei Dolgolyov..." line lived in a global app
footer that took up vertical space on every page. Move the author
+ contact details into the About tab of the global settings modal
(rendered by renderAboutPanel), where they sit next to the version
pill and license. Adds a localized "donation.about_author" key
(en/ru/zh) and matching .about-hero .about-author styles. Removes
the now-unused .app-footer / .footer-content rules.
2026-05-01 10:55:31 +03:00
alexei.dolgolyov 797b806972 feat: LED hot-path perf, tutorials expansion, modal markup polish
Performance (LED hot path, allocation-free per-frame):
- Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread
  overhead), pre-allocated wire buffer + uint8 scratch, header struct
  precomputed. New tests cover header format, buffer reuse, non-contiguous
  input, and brightness scaling.
- DDP: pre-built struct.Struct for the 10-byte header, allocation-free
  send buffer + memoryview emit path. New tests cover RGB/RGBW packets,
  sequence/PUSH semantics, and multi-packet fragmentation.
- Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices,
  fractional weights, take/blend scratch buffers) — per-frame work is now
  np.take + in-place blend, no allocations.
- WLED target processor: matching hot-path tightening.

Tutorials:
- Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks
  so a tour can open/close the dashboard customize panel and resolve
  targets behind sub-tabs.
- New steps for integrations tab, dashboard customize panel (presets,
  global, sections, perf cells), targets, scenes, sync-clocks.
- en/ru/zh locales updated with the new tour strings.

Dashboard layout:
- Structural deep-equal so the "modified" indicator reflects truth after
  a user edits then reverts, instead of a stale flag.

UI polish:
- Mod-card / modal markup pass across ~33 modal templates and the
  tutorial overlay partial.
- appearance.css, modal.css, tutorials.css refresh.

Tooling:
- Add .mcp.json with code-review-graph MCP server config so the graph
  tools are available to the team out of the box.
2026-05-01 03:02:13 +03:00
alexei.dolgolyov 9d4a534ec6 feat(ui): release notes overlay v2 + settings/streams/dashboard polish
Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
  UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
  chip strip + close/external action buttons; opts out of layout.css's
  global `header { height: 60px }` and `header::before` accent bar that
  were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
  asset match (exact basename, then same-extension token-overlap), with
  per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
  /portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
  apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).

Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.

Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.

Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
  source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.

Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
  doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
  the defaults test (was relying on isolation it never enforced).

Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
2026-04-29 17:14:05 +03:00
alexei.dolgolyov 51eebf21d5 feat(ui): redesign target pipeline as compact strip + chip row
Drops the legacy "Pipeline details" collapsible block on running
LED target cards. Instead:

- Always-visible 4px segmented timing bar (extract / map / smooth /
  send for video, read / fft / render / send for audio) — same
  stage colors as before, scaled by per-segment ms cost
- One chip row beneath it: total ms / frames count / keepalive
  count, using a new .chip--inline variant (display-weighted number
  + tiny mono-caps unit)
- _patchTargetMetrics now writes only the bar's segments and the
  data-tm spans — bar wrapper survives across polls so the
  flex-transition animates smoothly between samples
- _buildLedTimingHTML replaced by _buildLedTimingSegments (no more
  header / total / legend wrappers — those live in the chip row)

Cleanup
- Drop .target-metrics-collapse / -toggle / -animate / -expanded
  CSS — no callers remain
- Drop targets.metrics.pipeline from en/ru/zh locales — toggle
  label is gone
2026-04-27 01:52:24 +03:00
alexei.dolgolyov 9067db2639 feat(ui): align Targets metric cells with dashboard pattern
mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
  to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
  live-update patchers can target the value directly

LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
  FPS cell as a sibling of .v — was a separate target-fps-row
  block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
  meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
  based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
  element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
  current<span.dashboard-fps-target>/target</span>
  <span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
  styling for free

HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
  ha_connected); FPS icon dropped

Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
  minmax(min(380px, 100%), 1fr) / gap 14px — matches the
  dashboard's section grid so metric values like "1m 43s" stop
  truncating at the typical desktop width
2026-04-27 01:42:26 +03:00
alexei.dolgolyov 233b463ac3 feat(ui): migrate Targets cards to mod-card system
LED targets and HA Light targets adopt the dashboard's instrument-
readout vocabulary (mod-head, mod-leds, mod-metrics, mod-foot,
mod-patch, mod-btn, kebab menu) — same classes and tokens already
used by Dashboard and device cards.

mod-card.ts
- ModBtnOpts.dataAttrs for arbitrary data-attrs (used by the LED
  preview toggle's data-led-preview-btn binding)
- ModBodyOpts.extraHtml escape-hatch for live-update widgets that
  don't fit the predefined slots (FPS sparkline canvas, entity
  swatch grid, collapsible pipeline metrics)

LED target card (targets.ts)
- Badge "LED · TGT" pairs with device "WLED · OUT"-style badges
- Meta row: device link → protocol badge → fps → pixel count
- LED bezel: 1-3 dots reflecting checking / streaming / online /
  offline / unreachable
- Headline metrics on running cards (FPS / ERR / UPTIME) preserve
  data-tm selectors so _patchTargetMetrics still patches in place
- Chips for CSS source link, brightness/value-source, threshold
- Patch indicator: STREAMING / UNREACHABLE / STANDBY / OFFLINE /
  CHECKING
- Foot: START/STOP go/stop variant + LED preview + Edit
- Kebab menu: Duplicate / Hide / Delete (replaces top-right trash)
- FPS sparkline + collapsible pipeline preserved via extraHtml
- Tag chips and LED preview panel appended after wrap (mirrors
  devices.ts pattern)

HA Light target card (ha-light-targets.ts)
- Badge "HA · LIGHT"
- Meta: HA source link → light count → update rate
- LEDs: blink running, fault when ha_connected === false, off idle
- Running metrics: RATE / UPTIME / HA status
- Patch: STREAMING / DISCONNECTED / STANDBY / NOT CONFIGURED
- Buttons keep [data-action] for initHALightTargetDelegation
- Live entity color swatches preserved via extraHtml

Misc
- Chip border-radius dropped from 999px (pill) to var(--lux-r-sm,
  3px) — sharp corners match badges/metrics/buttons elsewhere
- _patchTargetMetrics FPS readout uses <small> for the target
  fraction instead of the legacy target-fps-target span
2026-04-27 01:33:13 +03:00
alexei.dolgolyov de13f44f24 feat(autostart): suppress browser auto-open on Windows login
When the user enables "Start with Windows" in the installer, the app
launches on every PC login. Previously each login popped a fresh WebUI
tab, which is noisy for a tray-resident background service.

The autostart shortcut now passes --autostart to start-hidden.vbs, which
sets LEDGRAB_AUTOSTART=1 in the child env. __main__ checks this flag
alongside LEDGRAB_RESTART when deciding whether to open the browser.

Manual launches (desktop/start-menu shortcuts) and the installer's
post-install "Launch LedGrab" finish-page action are unchanged — they
don't pass the arg, so they still open the WebUI tab.
2026-04-26 23:41:03 +03:00
alexei.dolgolyov 1c9acc5afb feat(api-input): make SegmentPayload start/length optional
start defaults to 0, length defaults to led_count - start (the rest of
the strip from start). A single segment with only mode + color now
fills the entire strip — no more length: 9999 magic value clients have
to pass.

Buffer auto-grow only fires for segments with an explicit length past
the current end; implicit "to the end" segments adapt to the current
strip size.
2026-04-26 23:34:42 +03:00
alexei.dolgolyov a56569b02f feat(ui): cards redesign + settings, modal, toolbar polish
Dashboard cards (mod-card system)
- New mod-card / mod-menu modules backing dashboard cards
- Reworked card colors, sections, dashboard layout, perf charts
- Channel-stripe styling, hairline borders, signal-flow animation
  on running cards, refined metric grid

Multiselect bulk toolbar
- Replaced tri-state checkbox with explicit Select-all / Deselect-all
  icon buttons; both disable when not applicable
- Dim + slight blur on non-selected siblings during selection mode so
  the active picks pop; selected card gains a subtle lift + primary-color
  glow halo
- Bulk tick uses ICON_CHECK from the icon registry (was U+2713) and
  scale-pops in via a cubic-bezier overshoot keyframe
- Toolbar restyled with luxury gradient bg, top accent stripe, glass
  blur, neon hover glows on each button group

Settings modal
- Tab bar converted to icon-only (cog / hard-drive / bell / palette /
  refresh / help) so labels never overflow at any locale; title and
  aria-label preserve translated names. Tabs distribute evenly via
  flex: 1 1 0 + space-around — no overflow possible
- IconSelect auto-populates <option> elements when the underlying select
  is empty, fixing the blank notification triggers (root cause: setting
  .value on an empty select is a no-op)
- Tab activation calls scrollIntoView on the active button as a safety
  net for narrow viewports

Modal exit animation
- Added symmetric fadeOut + slideDown keyframes; .modal.closing applies
  them with animation-fill-mode: forwards
- Modal.forceClose() defers display:none until animationend (with timer
  fallback). State cleanup (focus, body lock, stack) runs immediately so
  callers querying state get correct values
- isOpen returns false during the close animation; open() cancels any
  in-flight close so re-open works during the animation
- prefers-reduced-motion disables all modal animations

Locale picker
- Dropped redundant English/Русский/中文 long-form labels — picker now
  shows only EN / RU / ZH
- IconSelect trigger/cell hides empty icon/label slots via :empty so the
  layout collapses cleanly for minimal items

Filter input (cards section)
- Embedded magnifier icon via data URI (no HTML change); monospace
  uppercase placeholder, lux-bg-0 background, neon focus ring with inset
  shadow + outer glow
- Reset button only shows when the input has content (CSS-only via
  :placeholder-shown sibling selector — JS-resilient)

Snack toast
- Glass background (gradient + backdrop-blur) with top channel-color
  accent stripe matching the modal/toolbar language
- Per-type --toast-ch drives border/glow/timer color (success → primary,
  error → danger, info → info)
- Undo button gets a tinted hover with channel-color halo

Top header toolbar
- Removed hairline border from .header-btn for a flatter look; hover
  keeps the subtle background tint and primary-color glow

Device URL hyperlink
- Styled .mod-meta__link to pick up the card's --ch accent (instead of
  inheriting browser-blue underline). Dotted underline at rest solidifies
  on hover; soft text-shadow glow; web icon dims at rest, brightens on
  hover

Misc
- ICON_CHECK and ICON_HARD_DRIVE added to the icon registry
- Existing card-redesign demos checked in under docs/
- Removed obsolete docs/plans/device-typed-configs.md
2026-04-26 03:10:16 +03:00
alexei.dolgolyov ccf4406349 Merge branch 'feat/device-event-notifications'
Configurable device-event notifications: snackbar + Web Notifications
for online/offline (configured targets) and discovery (new WLED/serial
on the LAN/USB) events, with per-event channel matrix and background
discovery toggle.
2026-04-25 17:49:30 +03:00
alexei.dolgolyov 8aa3a323d6 feat(notifications): device event notifications (snack + Web Notifications)
Surface device connection state changes (configured target online/offline)
and discovery events (new WLED on LAN, new serial port, devices that
disappear) through a configurable per-event channel matrix:
none / snack / OS / both.

- Backend: long-running mDNS browser + 10 s serial poller in
  core/devices/discovery_watcher.py, gated by user pref. Reuses the
  existing device_health_changed event for online/offline transitions.
  New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2
  schema (channel matrix + background-discovery flag + grace/debounce).
  13 new tests, full suite still 899 passing.
- Frontend: features/notifications-watcher.ts with startup-grace +
  flap-debounce + bulk-coalesce pipeline. Web Notifications API for the
  OS channel (no platform-specific code, works in PWA shell).
  New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle
  + permission row + test button. en/ru/zh translations.

Defaults: device_offline=both (urgent), online/discovered=snack, lost=none,
background discovery on. Already-configured devices are filtered from
discovery events to avoid double-notifications.
2026-04-25 17:49:20 +03:00
alexei.dolgolyov 8e109f32b9 fix(pwa): add mobile-web-app-capable meta tag
Chrome deprecated apple-mobile-web-app-capable in favor of the
standard mobile-web-app-capable. Add the new tag while keeping the
Apple variant for iOS Safari compatibility.
2026-04-25 15:36:59 +03:00
alexei.dolgolyov 033c1f6a92 ci: add workflow_dispatch and skip lint/test on release commits
Release-bump commits don't change code that affects lint/tests, and
release.yml already runs in parallel. Manual dispatch lets us re-run
on demand if needed.
2026-04-25 15:36:51 +03:00
175 changed files with 22222 additions and 4943 deletions
+6
View File
@@ -5,9 +5,15 @@ on:
branches: [master]
pull_request:
branches: [master]
# Allow manual runs (e.g. to validate after a release commit was skipped).
workflow_dispatch:
jobs:
test:
# Skip release-publishing commits — version bumps don't affect lint/tests
# and the release.yml pipeline is already running. PRs and manual dispatch
# always run.
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
+2
View File
@@ -95,3 +95,5 @@ tmp/
# OS
Thumbs.db
.DS_Store
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -104,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
- Follow existing code style and patterns
- Update documentation when changing behavior
- Never make commits or pushes without explicit user approval
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+43 -25
View File
@@ -1,27 +1,42 @@
## v0.5.0 (2026-04-25)
## v0.6.0 (2026-05-01)
This release ships the **Lumenworks studio-console** — a top-to-bottom WebUI redesign — plus a customizable per-account dashboard, a server-shutdown control, and a handful of dark/light/narrow-screen polish fixes.
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
### Features
- **Lumenworks studio-console WebUI redesign** — new visual language across the entire WebUI: studio-console layout, refined typography, accent system, and motion. ([539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431))
- Extend the Lumenworks treatment to the **Inputs**, **Integrations**, and **Graph** tabs so the redesign is consistent across all top-level views. ([b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf))
- **Per-account customizable dashboard** with a slide-in configuration panel — each user can pick their own widget layout, persisted per account. ([56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7))
- Dashboard polish: richer performance strip, transport-bar controls, and additional readouts on the main view. ([e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9))
- Item-card restyle with hover-driven performance tooltips and a configurable FPS ceiling. ([70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1))
- **Live card-color picker** — pick a custom color per card and see it apply instantly; default preset now uses the base palette. Monotonic uptime ticker no longer jitters on clock adjustments. ([e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f))
- **Server shutdown action** exposed in the WebUI, backed by a public `cancel_task` lifecycle method so long-running tasks unwind cleanly. ([3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2))
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
### Performance
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
### Bug Fixes
- Channel stripe on item cards now only paints when the card has a custom color or is running — no more stray accents on idle defaults. ([b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3))
- Cards render correctly on pure black and pure white backgrounds, and are decoupled from the animated background so they stay legible regardless of the bg-anim setting. ([dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2))
- Single-row header layout and readable sidebar labels at narrow widths — fixes wrapping and label truncation on smaller windows. ([2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304))
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
---
### Development / Internal
#### Chores
- Harden test isolation, add `.gitignore` rule for stale `src/data/`, and mark the shutdown action done in the task tracker. ([80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4))
#### CI/Build
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
#### Tests
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
#### Tooling
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
---
@@ -30,16 +45,19 @@ This release ships the **Lumenworks studio-console** — a top-to-bottom WebUI r
| Hash | Message | Author |
|------|---------|--------|
| [80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4) | chore: harden test isolation, gitignore stale src/data, mark shutdown action done | alexei.dolgolyov |
| [b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3) | fix(ui): channel stripe paints only on custom-color or running cards | alexei.dolgolyov |
| [e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f) | feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette | alexei.dolgolyov |
| [3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2) | feat: server shutdown action with public cancel_task lifecycle method | alexei.dolgolyov |
| [2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304) | fix(ui): single-row header + readable sidebar labels at narrow widths | alexei.dolgolyov |
| [dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2) | fix(ui): cards on pure black/white, decoupled from bg-anim | alexei.dolgolyov |
| [b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf) | feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs | alexei.dolgolyov |
| [56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7) | feat(dashboard): per-account customizable dashboard with slide-in panel | alexei.dolgolyov |
| [70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1) | feat(ui): item-card restyle, perf hover tooltips, FPS ceiling | alexei.dolgolyov |
| [e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9) | feat(ui): dashboard polish, richer perf strip, transport-bar controls | alexei.dolgolyov |
| [539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431) | feat(ui): Lumenworks studio-console WebUI redesign | alexei.dolgolyov |
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
</details>
+60
View File
@@ -1,5 +1,65 @@
# LedGrab TODO
## Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new
WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a
configurable channel: `none` | `snack` | `os` | `both`. OS channel uses Web Notifications
(works in any browser tab and in the PWA shell — no platform-specific Python).
Branch: `feat/device-event-notifications`. Default ON.
### Backend
- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser
(`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port
poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`,
suppresses events for URLs already in `device_store`. Seeded ports do NOT generate
startup-time toasts.
- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences.
background_discovery_enabled`. Default True. Stops before health monitor stop.
- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with
the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec`
(0..300), `flap_debounce_sec` (0..60).
- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`,
persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored
values fall back to defaults instead of 500.
- [x] Reuses existing `device_health_changed` event from `device_health.py` (already
fires online/offline transitions on the same event bus).
- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in
`tests/test_discovery_watcher.py`. Full pytest suite still 899 passing.
### Frontend
- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM
events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk
coalesce (≥3 events / 800 ms collapse to one summary).
- [x] Web Notification permission requested from the Settings → Notifications panel
via a user-gesture button. State chip reflects granted/denied/default.
- [x] Settings panel — new "Notifications" subtab between Backup and Appearance.
4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle +
permission row + Test-notification button.
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
### Verification
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
- [x] App import smoke-test (`from ledgrab.main import app`) loads 233 routes
without errors.
- [ ] Real-hardware test pending — verify on user's network:
(1) plug a fresh WLED in → snack toast appears, (2) configure it → next
offline transition fires both snack + OS toast, (3) Background-discovery
toggle off → no more discovered/lost events.
### Out of scope for v1
- Per-device-type granularity (we ship one matrix per event-type, no device-type split)
- Per-device mute list (deferred — user can globally toggle off if noisy)
- Native OS toast via Windows winrt API (Web Notifications cover the use case;
also avoids the `os_notification_listener` feedback loop)
- Notification history panel — could land later as the reserved `alerts` dashboard cell
## Server shutdown action
Let user choose what happens to LED targets on server shutdown.
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.5.0"
versionName = "0.6.0"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+4 -1
View File
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
SectionEnd
Section "Start with Windows" SecAutostart
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
; the browser auto-open on Windows login. Manual launches (desktop / start
; menu) don't pass the arg, so they keep opening the WebUI tab.
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-274
View File
@@ -1,274 +0,0 @@
# Refactor Plan: Per-Provider Typed Device Configs
**Status:** Planned, not started.
**Target branch:** `refactor/device-typed-configs`
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
## Goal
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
## Motivation
Current pain points:
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
- Adding a new per-device-type field requires threading it through `Device``DeviceInfo``_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
## Scope guardrails
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
---
## Phase 1 — Config hierarchy (foundation, non-breaking)
### Create
**File:** `server/src/ledgrab/core/devices/device_config.py`
Pattern:
```python
from dataclasses import dataclass
from typing import List, Literal, Optional, Union
@dataclass(frozen=True)
class BaseDeviceConfig:
device_id: str
device_url: str
led_count: int
software_brightness: int = 255
test_mode_active: bool = False
auto_shutdown: bool = False
rgbw: bool = False
@dataclass(frozen=True)
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
use_ddp: bool = False
# ... one @dataclass(frozen=True) per provider
```
### Config field inventory
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
| Config | Extra fields beyond Base |
| -------------- | ------------------------ |
| WLEDConfig | `use_ddp: bool = False` |
| AdalightConfig | `baud_rate: Optional[int] = None` |
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
| ChromaConfig | `chroma_device_type` |
| GameSenseConfig| `gamesense_device_type` |
| BLEConfig | `ble_family`, `ble_govee_key` |
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
| OpenRGBConfig | `zone_mode` |
| MockConfig | `send_latency_ms: int = 0` |
| DemoConfig | `send_latency_ms: int = 0` |
| MQTTConfig | (none) |
| WSConfig | (none) |
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
```python
DeviceConfig = Union[
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
MockConfig, DemoConfig,
]
```
### Add
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
- Dispatches on `self.device_type`.
- Constructs the right subclass, pulling only relevant columns.
- Ignores columns that don't apply to the type.
- This is the **only** place that knows the flat→typed mapping.
### Do NOT touch in Phase 1
- Provider signatures (still `create_client(self, url, **kwargs)`).
- `create_led_client` factory.
- Any call site.
- `DeviceInfo` itself.
### Acceptance
- New unit test `server/tests/core/devices/test_device_config.py`:
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
- `cd server && ruff check src/ tests/ --fix` — green.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
---
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
### Change the abstract base
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
```python
class LEDDeviceProvider(ABC):
@abstractmethod
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
```
`ProviderDeps` is a tiny new dataclass:
```python
@dataclass(frozen=True)
class ProviderDeps:
device_store: "DeviceStore"
# Add future cross-cutting runtime deps here (http_client, etc.)
```
`create_led_client`:
```python
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
return get_provider(config.device_type).create_client(config, deps=deps)
```
### Update every provider (17 files)
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
- Providers that don't need `deps` just ignore it.
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
### Call sites (3)
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
```python
config = device.to_config()
self._led_client = create_led_client(config, deps=self._provider_deps)
```
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
### Delete
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
### Acceptance
- `ast-index search "device_info\."` — no hits in non-test code.
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
- `cd server && ruff check src/ tests/ --fix` — green.
---
## Phase 4 — Test migration
Update these files:
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
---
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
### Backend
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
```python
class WLEDDeviceCreate(BaseModel):
device_type: Literal["wled"]
name: str
url: str
led_count: int
use_ddp: bool = False
# ... base fields only
DeviceCreate = Annotated[
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
Field(discriminator="device_type"),
]
```
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
### Frontend
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
### Tests
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
- Add round-trip tests: create device of each type via API → fetch → compare fields.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
- `cd server && npx tsc --noEmit && npm run build` — green.
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
- HAOS integration still works against the server (spot-check; not automated).
---
## Conventions the implementing agent must follow
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
- **No commits without explicit user approval.** Present each phase's diff for review first.
- **Pre-commit gate every phase:**
- `cd server && ruff check src/ tests/ --fix`
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
## Known risks
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
## Deliverables per phase
1. Branch: `refactor/device-typed-configs`.
2. One commit per phase, conventional-commit messages:
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
- `refactor(devices): phase 4 — test migration to typed configs`
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
3. Phase-by-phase diffs presented for user review **before** each commit.
4. Final PR body linking all phases, with manual test plan per device type touched.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.5.0"
version = "0.6.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+9
View File
@@ -10,6 +10,15 @@ Set procEnv = WshShell.Environment("Process")
procEnv("PYTHONPATH") = appRoot & "\app\src"
procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
' If launched as Windows autostart (via the SMSTARTUP shortcut), suppress the
' browser auto-open. Manual launches (desktop / start menu) pass no args.
For Each arg In WScript.Arguments
If arg = "--autostart" Then
procEnv("LEDGRAB_AUTOSTART") = "1"
Exit For
End If
Next
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
' Same pattern as the Media Server sibling app.
embeddedPython = appRoot & "\python\python.exe"
+12 -2
View File
@@ -83,6 +83,16 @@ def _is_restart() -> bool:
return os.environ.get("LEDGRAB_RESTART", "") == "1"
def _is_autostart() -> bool:
"""Detect if launched via the Windows autostart shortcut."""
return os.environ.get("LEDGRAB_AUTOSTART", "") == "1"
def _should_skip_browser() -> bool:
"""Skip auto-opening the browser on restarts and on Windows login autostart."""
return _is_restart() or _is_autostart()
def _check_port(host: str, port: int) -> None:
"""Exit with a clear message if the port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -120,8 +130,8 @@ def main() -> None:
)
server_thread.start()
# Browser after a short delay (skip on restart — user already has a tab)
if not _is_restart():
# Browser after a short delay (skip on restart and on Windows login autostart)
if not _should_skip_browser():
threading.Thread(
target=_open_browser,
args=(config.server.port,),
@@ -4,7 +4,6 @@ from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorStop as ColorStopSchema,
ColorStripSourceResponse,
CompositeCSSResponse,
@@ -31,7 +30,6 @@ from ledgrab.storage.color_strip_source import (
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
@@ -121,10 +119,6 @@ _RESPONSE_MAP: dict = {
easing=s.easing,
gradient_id=s.gradient_id,
),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
@@ -31,7 +31,6 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = {
"static",
"gradient",
"color_cycle",
"effect",
"daylight",
"candlelight",
@@ -476,13 +475,16 @@ async def test_color_strip_ws(
meta["layer_infos"] = layer_infos
await websocket.send_text(_json.dumps(meta))
# For api_input: send the current buffer immediately so the client
# gets a frame right away (fallback color if inactive) rather than
# leaving the canvas blank/stale until external data arrives.
# For api_input: only send an initial frame if a client has actually
# pushed data (push_generation > 0). Without prior data, the preview
# stays blank instead of showing the fallback buffer as a stray frame.
if is_api_input:
initial_colors = stream.get_latest_colors()
if initial_colors is not None:
await websocket.send_bytes(initial_colors.tobytes())
initial_gen = stream.push_generation
if initial_gen > 0:
_last_push_gen = initial_gen
initial_colors = stream.get_latest_colors()
if initial_colors is not None:
await websocket.send_bytes(initial_colors.tobytes())
# For picture sources, grab the live stream for frame preview
_frame_live = None
@@ -316,6 +316,7 @@ async def get_ha_status(
name=source.name,
connected=connected,
entity_count=status["entity_count"] if status else 0,
host=source.host or "",
)
)
@@ -12,6 +12,7 @@ from fastapi.responses import Response
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
)
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
@@ -361,11 +363,12 @@ async def delete_picture_source(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a picture source."""
try:
# Check if any target references this stream
target_names = store.get_targets_referencing(stream_id, target_store)
# Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
@@ -373,6 +376,16 @@ async def delete_picture_source(
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
)
# Block when any CSS still references this picture source, even if no
# target depends on it — deletion would leave the CSS broken.
css_refs = css_store.get_referencing_picture_source(stream_id)
if css_refs:
css_names = ", ".join(css.name for css in css_refs)
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is used by color strip source(s): "
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
+129 -1
View File
@@ -1,17 +1,33 @@
"""User preferences routes — currently dashboard layout only.
"""User preferences routes — dashboard layout + notification settings + daylight tz.
The dashboard layout schema is owned by the frontend (open registry of
section/cell keys); the backend treats the value as an opaque JSON blob,
validates it's a dict with a `version` field, and persists it under the
`dashboard_layout` settings key.
Notification preferences are validated server-side via Pydantic so the
backend can read them when deciding whether to start the background
discovery watcher.
Daylight timezone is a single global IANA tz name shared by every
daylight value-source / color-strip-source. Stored as
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
empty/missing meaning "use system local time".
"""
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, Field
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.api.schemas.preferences import NotificationPreferences
from ledgrab.core.processing.daylight_settings import (
DAYLIGHT_TIMEZONE_KEY,
get_daylight_timezone,
set_daylight_timezone,
)
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -20,6 +36,33 @@ logger = get_logger(__name__)
router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
class DaylightTimezonePreference(BaseModel):
"""Global IANA timezone applied to every daylight cycle source."""
timezone: str = Field("", description="IANA timezone name; empty = system local")
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
"""Read notification prefs, returning defaults when unset or corrupt.
Used by both the route handler and `main.lifespan` (so the discovery
watcher can decide whether to start without going through HTTP).
"""
if db is None:
from ledgrab.api.dependencies import get_database as _get_db
db = _get_db()
raw = db.get_setting(_NOTIFICATION_PREFS_KEY)
if not raw:
return NotificationPreferences()
try:
return NotificationPreferences.model_validate(raw)
except Exception as e:
logger.warning("Stored notification preferences invalid (%s); using defaults", e)
return NotificationPreferences()
@router.get(
@@ -73,3 +116,88 @@ async def delete_dashboard_layout(
to clear the server-side override entirely."""
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Notification preferences
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/preferences/notifications",
response_model=NotificationPreferences,
tags=["Preferences"],
)
async def get_notification_preferences(
_: AuthRequired,
db: Database = Depends(get_database),
) -> NotificationPreferences:
"""Read notification prefs, returning defaults when unset.
Defaults: device_offline=both, device_online/discovered=snack,
device_lost=none, background discovery on, 10 s startup grace,
5 s flap debounce.
"""
return load_notification_preferences(db)
@router.put(
"/api/v1/preferences/notifications",
response_model=NotificationPreferences,
tags=["Preferences"],
)
async def put_notification_preferences(
_: AuthRequired,
body: NotificationPreferences,
db: Database = Depends(get_database),
) -> NotificationPreferences:
"""Persist the notification prefs. Pydantic enforces channel
enum + grace/debounce ranges so a bad client cannot poison
the stored value."""
db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump())
logger.info(
"Notification preferences updated (background_discovery=%s, " "channels=%s)",
body.background_discovery_enabled,
body.channels.model_dump(),
)
return body
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def get_daylight_timezone_preference(
_: AuthRequired,
) -> DaylightTimezonePreference:
"""Return the global daylight cycle timezone (empty = system local)."""
return DaylightTimezonePreference(timezone=get_daylight_timezone())
@router.put(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def put_daylight_timezone_preference(
_: AuthRequired,
body: DaylightTimezonePreference,
) -> DaylightTimezonePreference:
"""Persist the global daylight cycle timezone.
The string is stored verbatim — clients should send a valid IANA name
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
Daylight streams pick up the new value within ~1 second.
"""
saved = set_daylight_timezone(body.timezone)
logger.info("Daylight timezone updated: %r", saved or "<system local>")
return DaylightTimezonePreference(timezone=saved)
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
+7 -1
View File
@@ -8,6 +8,7 @@ from ledgrab.api.dependencies import (
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
get_value_source_store,
)
from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate,
@@ -18,6 +19,7 @@ from ledgrab.api.schemas.sync_clocks import (
from ledgrab.storage.sync_clock import SyncClock
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
@@ -137,14 +139,18 @@ async def delete_sync_clock(
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
vs_store: ValueSourceStore = Depends(get_value_source_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
for vs in vs_store.get_all_sources():
if getattr(vs, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
@@ -255,6 +255,7 @@ async def list_engines(_auth: AuthRequired):
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
config_choices=engine_class.get_config_choices(),
available=(engine_type in available_set),
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
)
@@ -105,6 +105,7 @@ _RESPONSE_MAP = {
speed=s.speed,
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
min_value=s.min_value,
max_value=s.max_value,
),
@@ -127,6 +128,7 @@ _RESPONSE_MAP = {
colors=[list(c) for c in s.colors],
speed=s.speed,
easing=s.easing,
clock_id=s.clock_id,
),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id,
@@ -28,7 +28,7 @@ class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources."""
enabled: bool = True
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
type: str = "breathing" # breathing | gradient_shift | wave
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
@@ -126,11 +126,6 @@ class GradientCSSResponse(_CSSResponseBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSResponse(_CSSResponseBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
@@ -241,7 +236,6 @@ ColorStripSourceResponse = Annotated[
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[StaticCSSResponse, Tag("static")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
@@ -303,11 +297,6 @@ class GradientCSSCreate(_CSSCreateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSCreate(_CSSCreateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -431,7 +420,6 @@ ColorStripSourceCreate = Annotated[
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
@@ -493,11 +481,6 @@ class GradientCSSUpdate(_CSSUpdateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSUpdate(_CSSUpdateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -619,7 +602,6 @@ ColorStripSourceUpdate = Annotated[
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
@@ -655,10 +637,22 @@ class ColorStripSourceListResponse(BaseModel):
class SegmentPayload(BaseModel):
"""A single segment for segment-based LED color updates."""
"""A single segment for segment-based LED color updates.
start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
``start`` and ``length`` are optional: when omitted, the segment defaults
to ``start=0`` and ``length=led_count - start`` (i.e. the rest of the
strip from ``start``). Sending a single segment with only ``mode`` and
``color`` therefore fills the entire strip.
"""
start: Optional[int] = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
)
length: Optional[int] = Field(
None,
ge=1,
description="Number of LEDs in segment (default = led_count - start)",
)
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(
@@ -94,6 +94,7 @@ class HomeAssistantConnectionStatus(BaseModel):
name: str
connected: bool
entity_count: int
host: str = ""
class HomeAssistantStatusResponse(BaseModel):
@@ -280,6 +280,9 @@ class TargetProcessingState(BaseModel):
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
None, description="Configured capture-side FPS for the underlying color strip stream"
)
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
@@ -0,0 +1,66 @@
"""User-preference schemas (notifications, future per-user settings)."""
from typing import Literal
from pydantic import BaseModel, Field
NotificationChannel = Literal["none", "snack", "os", "both"]
class NotificationChannelMatrix(BaseModel):
"""Channel selection per device-event type."""
device_online: NotificationChannel = Field(
default="snack",
description="Configured device transitioned from offline to online",
)
device_offline: NotificationChannel = Field(
default="both",
description="Configured device went offline (urgent — likely user wants OS toast)",
)
device_discovered: NotificationChannel = Field(
default="snack",
description="A new WLED/serial device appeared on the LAN/USB",
)
device_lost: NotificationChannel = Field(
default="none",
description=(
"Previously discovered (but never configured) device disappeared. "
"Default off — usually noise unless the user is actively pairing."
),
)
class NotificationPreferences(BaseModel):
"""User-level notification preferences."""
channels: NotificationChannelMatrix = Field(
default_factory=NotificationChannelMatrix,
description="Per-event-type channel selection",
)
background_discovery_enabled: bool = Field(
default=True,
description=(
"Run the continuous mDNS browser + serial-port poller while the server "
"is up. Required for device_discovered/device_lost notifications. "
"Disable to silence all discovery-driven events at the source."
),
)
startup_grace_sec: int = Field(
default=10,
ge=0,
le=300,
description=(
"Seconds after each event-WS connect during which device_offline "
"notifications are suppressed (devices boot at different speeds)."
),
)
flap_debounce_sec: int = Field(
default=5,
ge=0,
le=60,
description=(
"A device must hold a new state for at least this many seconds before "
"the corresponding notification is fired. Filters out single-packet drops."
),
)
@@ -52,6 +52,10 @@ class EngineInfo(BaseModel):
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
config_choices: Dict[str, List[str]] = Field(
default_factory=dict,
description="Allowed values for enum-like config keys on this platform",
)
available: bool = Field(description="Whether engine is available on this system")
has_own_displays: bool = Field(
default=False, description="Engine has its own device list (not desktop monitors)"
+9
View File
@@ -3,6 +3,14 @@
from pydantic import BaseModel, Field
class UpdateAssetInfo(BaseModel):
"""A downloadable asset attached to a release (e.g. an installer)."""
name: str
size: int
download_url: str
class UpdateReleaseInfo(BaseModel):
version: str
tag: str
@@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel):
body: str
prerelease: bool
published_at: str
assets: list[UpdateAssetInfo] = Field(default_factory=list)
class UpdateStatusResponse(BaseModel):
@@ -73,6 +73,7 @@ class DaylightValueSourceResponse(_ValueSourceResponseBase):
speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
@@ -87,8 +88,11 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute")
easing: str = Field(description="Color easing: linear|step")
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
@@ -215,6 +219,9 @@ class DaylightValueSourceCreate(_ValueSourceCreateBase):
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float = Field(
0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
@@ -234,7 +241,12 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
description="Color list [[R,G,B], ...]",
)
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step")
easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (overrides speed when set)"
)
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
@@ -356,6 +368,9 @@ class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.0
)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
@@ -369,7 +384,12 @@ class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step")
easing: Optional[str] = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
+172 -63
View File
@@ -233,6 +233,90 @@ class CalibrationConfig:
return None
def _build_skip_buffers(mapper, calibration: CalibrationConfig, total_leds: int) -> None:
"""Pre-compute Phase 3 skip-LED resampling indices and scratch buffers.
Phase 3 takes the full ``total_leds`` strip and resamples it into
``active_count = total_leds - skip_start - skip_end`` LEDs using linear
interpolation. We precompute floor/ceil source indices and fractional
weights once so per-frame work becomes a couple of ``np.take`` +
in-place arithmetic ops with no allocations.
Attaches all skip-related state to ``mapper`` directly to keep the
storage layout consistent between PixelMapper and AdvancedPixelMapper.
"""
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
mapper._skip_start = skip_start
mapper._skip_end = skip_end
active_count = max(0, total_leds - skip_start - skip_end)
mapper._active_count = active_count
if not (0 < active_count < total_leds):
# No skip needed (full strip used) or no active LEDs.
mapper._skip_floor_idx = None
mapper._skip_ceil_idx = None
mapper._skip_frac = None
mapper._skip_left_u8 = None
mapper._skip_right_u8 = None
mapper._skip_blend_f32 = None
mapper._skip_resampled = None
return
# Floor/ceil source indices and fractional weights for each
# destination LED. ``t = src_x[k] = k * (total_leds - 1) / (active_count - 1)``
# — equivalent to ``np.linspace(0, total_leds - 1, active_count)``.
if active_count > 1:
t = np.arange(active_count, dtype=np.float64) * ((total_leds - 1) / (active_count - 1))
else:
t = np.zeros(active_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, total_leds - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, total_leds - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (active_count, 1)
mapper._skip_floor_idx = floor_idx
mapper._skip_ceil_idx = ceil_idx
mapper._skip_frac = frac
# uint8 take destinations + float32 blend scratch — all reused per frame
mapper._skip_left_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_right_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_blend_f32 = np.empty((active_count, 3), dtype=np.float32)
mapper._skip_resampled = np.empty((active_count, 3), dtype=np.uint8)
def _apply_skip_resample(mapper, led_array: np.ndarray) -> None:
"""Phase 3 in-place resample of ``led_array`` (no allocations).
Applies linear interpolation precomputed in ``_build_skip_buffers`` and
writes the result back into ``led_array`` with the configured skip
leading/trailing zeros.
"""
floor_idx = mapper._skip_floor_idx
if floor_idx is None:
if mapper._active_count <= 0:
led_array[:] = 0
return
left_u8 = mapper._skip_left_u8
right_u8 = mapper._skip_right_u8
blend = mapper._skip_blend_f32
resampled = mapper._skip_resampled
np.take(led_array, floor_idx, axis=0, out=left_u8)
np.take(led_array, mapper._skip_ceil_idx, axis=0, out=right_u8)
np.copyto(blend, right_u8, casting="unsafe") # uint8 → float32
blend -= left_u8 # right - left
blend *= mapper._skip_frac # frac * (right - left)
blend += left_u8 # left + frac*(right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(resampled, blend, casting="unsafe") # float32 → uint8
led_array[:] = 0
end_idx = mapper._total_leds - mapper._skip_end
led_array[mapper._skip_start : end_idx] = resampled
class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration."""
@@ -280,19 +364,10 @@ class PixelMapper:
indices = (indices + offset) % total_leds
self._segment_indices.append(indices)
# Pre-compute Phase 3 skip arrays (static geometry)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Pre-compute Phase 3 skip — linear interpolation by precomputed
# floor/ceil indices and fractional weights. Per-frame work is
# entirely write-in-place into pre-allocated scratch buffers.
_build_skip_buffers(self, calibration, total_leds)
# Per-edge average computation cache (lazy-initialized on first frame)
self._edge_cache: Dict[str, tuple] = {}
@@ -357,8 +432,9 @@ class PixelMapper:
) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to
avoid per-frame allocations that cause GC-induced timing spikes.
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
buffers (lazy-initialized per edge). All per-frame numpy ops write
in-place — zero allocations on the hot path.
"""
if edge_name in ("top", "bottom"):
axis = 0
@@ -369,7 +445,7 @@ class PixelMapper:
# Lazy-init / resize per-edge scratch buffers
cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len:
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -379,20 +455,53 @@ class PixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[edge_name] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumsum into pre-allocated buffer
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
# fancy-index expression allocates. np.take with ``out=`` writes
# directly into our pre-allocated scratch.
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -423,18 +532,9 @@ class PixelMapper:
led_array[self._segment_indices[i]] = colors
# Phase 3: Physical skip — resample full perimeter to active LEDs
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
# Phase 3: physical skip — resample full perimeter into active LEDs
# using precomputed weights, all in-place.
_apply_skip_resample(self, led_array)
return led_array
@@ -514,19 +614,8 @@ class AdvancedPixelMapper:
self._line_indices.append(indices)
led_start += line.led_count
# Skip arrays (same logic as PixelMapper)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Skip arrays — share the same buffer layout as PixelMapper
_build_skip_buffers(self, calibration, total_leds)
# Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {}
@@ -586,7 +675,7 @@ class AdvancedPixelMapper:
edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len:
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -596,15 +685,45 @@ class AdvancedPixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[cache_key] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def _map_edge_fallback(
self,
@@ -672,18 +791,8 @@ class AdvancedPixelMapper:
led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper)
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
# Phase 3: physical skip same precomputed-weight resample as PixelMapper
_apply_skip_resample(self, led_array)
return led_array
@@ -117,6 +117,16 @@ class CaptureEngine(ABC):
"""Get default configuration for this engine."""
pass
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
"""Return allowed values for enum-like config keys on this platform.
Keys returned here narrow the values the UI offers for the
corresponding config field. Engines that have no platform-specific
constraints can leave this empty (default).
"""
return {}
@classmethod
@abstractmethod
def get_available_displays(cls) -> List[DisplayInfo]:
@@ -8,12 +8,19 @@ Prerequisites (optional dependency):
pip install opencv-python-headless>=4.8.0
"""
import os
import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional, Set
# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
# hardware MFTs are enabled. Disabling them is the documented mitigation.
# Set before any cv2 import so the MSMF backend picks it up on first use.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from ledgrab.core.capture_engines.base import (
CaptureEngine,
@@ -27,6 +34,41 @@ logger = get_logger(__name__)
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
# OpenCV will clamp the requested width/height down to the nearest supported mode.
_PROBE_MAX_DIM = 9999
# Resolution presets shown in the UI. "auto" means: open at the camera's max
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
_RESOLUTION_CHOICES: List[str] = [
"auto",
"640x480",
"1280x720",
"1920x1080",
"2560x1440",
"3840x2160",
]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str):
return None
s = value.strip().lower()
if s in ("", "auto"):
return None
parts = s.replace("×", "x").split("x")
if len(parts) != 2:
return None
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
return None
if w <= 0 or h <= 0:
return None
return w, h
# Process-wide registry of cv2 camera indices currently held open.
# Prevents _enumerate_cameras from probing an in-use camera (which can
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
@@ -48,6 +90,85 @@ def _get_default_backend():
return "auto"
# Maps our backend ids to the label cv2.getBuildInformation() prints in the
# Video I/O section. Entries missing or marked "NO" mean the installed
# opencv wheel was compiled without that backend — even if cv2's registry
# still lists it, attempts to open will fail with isOpened()==False.
_BUILDINFO_LABELS: Dict[str, str] = {
"dshow": "DirectShow",
"msmf": "Media Foundation",
"v4l2": "v4l/v4l2",
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
def _get_compiled_backends() -> Set[str]:
"""Return the set of backend ids the installed cv2 was compiled with.
Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
advertise backends that aren't actually functional (e.g. wheels that
omit Media Foundation still list MSMF in the registry).
"""
global _compiled_backends_cache
if _compiled_backends_cache is not None:
return _compiled_backends_cache
try:
import cv2
except ImportError:
_compiled_backends_cache = set()
return _compiled_backends_cache
info = cv2.getBuildInformation()
# Restrict the search to the "Video I/O" section so labels like
# "Media Foundation" don't pick up unrelated mentions elsewhere.
start = info.find("Video I/O:")
section = info[start:] if start != -1 else info
end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
for marker in end_markers:
idx = section.find(marker)
if idx != -1:
section = section[:idx]
break
found: Set[str] = set()
for backend, label in _BUILDINFO_LABELS.items():
# Match "<label>: <whitespace>YES" anywhere in the section.
needle = label + ":"
pos = section.find(needle)
if pos == -1:
continue
line_end = section.find("\n", pos)
line = section[pos : line_end if line_end != -1 else len(section)]
if "YES" in line.upper():
found.add(backend)
_compiled_backends_cache = found
return found
def _get_supported_backends() -> List[str]:
"""Return the list of cv2 backends that make sense on this platform.
Only advertises backends that are both (a) appropriate for the host OS
and (b) actually compiled into the installed opencv wheel. ``auto`` is
always offered as a safe default.
"""
if sys.platform == "win32":
candidates = ["dshow", "msmf"]
elif sys.platform.startswith("linux"):
candidates = ["v4l2"]
elif sys.platform == "darwin":
candidates = ["avfoundation"]
else:
candidates = []
compiled = _get_compiled_backends()
return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]:
"""Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name)
@@ -256,8 +377,20 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
cap.release()
continue
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Probe the camera's max supported mode by asking for an absurdly large
# frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
# If the probe is rejected (rare driver issue), fall back to the default
# mode that the camera reports immediately after open.
default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
try:
cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
except Exception as e:
logger.debug(f"Camera {i} max-resolution probe failed: {e}")
width, height = default_width, default_height
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
@@ -328,16 +461,28 @@ class CameraCaptureStream(CaptureStream):
_active_cv2_indices.add(cv2_index)
try:
# Open the camera
# Open the camera. MSMF's first open after a DShow session (or its
# very first cold open in the process) is timing-sensitive on
# Windows, so retry briefly before giving up.
backend_id = _cv2_backend_id(backend_name)
if backend_id is not None:
self._cap = cv2.VideoCapture(cv2_index, backend_id)
else:
self._cap = cv2.VideoCapture(cv2_index)
attempts = 3 if backend_name == "msmf" else 1
self._cap = None
for attempt in range(attempts):
if backend_id is not None:
cap = cv2.VideoCapture(cv2_index, backend_id)
else:
cap = cv2.VideoCapture(cv2_index)
if cap.isOpened():
self._cap = cap
break
cap.release()
if attempt + 1 < attempts:
time.sleep(0.5)
if not self._cap.isOpened():
if self._cap is None or not self._cap.isOpened():
raise RuntimeError(
f"Failed to open camera {self.display_index} " f"(cv2 index {cv2_index})"
f"Failed to open camera {self.display_index} "
f"(cv2 index {cv2_index}, backend={backend_name})"
)
except Exception:
with _camera_lock:
@@ -346,12 +491,28 @@ class CameraCaptureStream(CaptureStream):
self._cv2_index = cv2_index
# Apply optional resolution override
res_w = self.config.get("resolution_width", 0)
res_h = self.config.get("resolution_height", 0)
if res_w > 0 and res_h > 0:
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
# Resolve effective resolution.
# Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
# → new `resolution` enum string (e.g. "1920x1080" or "auto")
# → "auto" (open at the camera's max).
# On Windows DShow/MSMF the default opening mode is typically 640x480
# regardless of the camera's hardware ceiling, so when no explicit
# override is given we ask for the highest mode the device supports
# by setting an absurdly large frame size — drivers clamp down to the
# nearest supported mode.
legacy_w = self.config.get("resolution_width", 0) or 0
legacy_h = self.config.get("resolution_height", 0) or 0
if legacy_w > 0 and legacy_h > 0:
target_w, target_h = legacy_w, legacy_h
else:
parsed = _parse_resolution(self.config.get("resolution", "auto"))
if parsed is not None:
target_w, target_h = parsed
else:
target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
# Test read
ret, frame = self._cap.read()
@@ -434,10 +595,20 @@ class CameraEngine(CaptureEngine):
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
# `resolution` is the user-facing control. Legacy numeric overrides
# `resolution_width`/`resolution_height` are still honored if present
# in stored configs (see CameraCaptureStream.initialize), but are no
# longer surfaced in the default config — the dropdown replaces them.
return {
"camera_backend": _get_default_backend(),
"resolution_width": 0,
"resolution_height": 0,
"resolution": "auto",
}
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
return {
"camera_backend": _get_supported_backends(),
"resolution": list(_RESOLUTION_CHOICES),
}
@classmethod
-17
View File
@@ -32,7 +32,6 @@ _PS_IDS = {
_CSS_IDS = {
"gradient": "css_demo0001",
"cycle": "css_demo0002",
"picture": "css_demo0003",
"audio": "css_demo0004",
}
@@ -267,22 +266,6 @@ def _build_color_strip_sources() -> dict:
"created_at": _NOW,
"updated_at": _NOW,
},
_CSS_IDS["cycle"]: {
"id": _CSS_IDS["cycle"],
"name": "Warm Color Cycle",
"source_type": "color_cycle",
"description": "Smoothly cycles through warm colors",
"clock_id": None,
"tags": ["demo"],
"colors": [
[255, 60, 0],
[255, 140, 0],
[255, 200, 50],
[255, 100, 20],
],
"created_at": _NOW,
"updated_at": _NOW,
},
_CSS_IDS["picture"]: {
"id": _CSS_IDS["picture"],
"name": "Screen Capture — Main Display",
@@ -1,6 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio
import concurrent.futures
from datetime import datetime, timezone
from typing import Optional, Tuple
@@ -56,15 +57,38 @@ class AdalightClient(LEDClient):
# Pre-compute Adalight header if led_count is known
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
self._header_len = len(self._header)
# Pre-allocate numpy buffer for brightness scaling
self._pixel_buf = None
# Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None
self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None
self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call
# ``contextvars.copy_context()`` and ``functools.partial`` overhead
# that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset."""
try:
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
await asyncio.to_thread(self._serial.open)
# Single-worker executor — created here so the thread is bound
# to this client's lifecycle (started on connect, shut down on
# close). ``thread_name_prefix`` makes it identifiable in
# diagnostics.
self._tx_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=f"adalight-tx-{self._port}",
)
await asyncio.get_running_loop().run_in_executor(self._tx_executor, self._serial.open)
# Wait for Arduino to finish bootloader reset (non-blocking).
# USB-to-TTL adapters without DTR don't reset, but the delay
# is harmless on those — keeps the path uniform.
@@ -77,11 +101,22 @@ class AdalightClient(LEDClient):
return True
except Exception as e:
logger.error(f"Failed to open serial port {self._port}: {e}")
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None:
"""Send black frame and close the serial port."""
if self._connected and self._serial and self._serial.is_open and self._led_count > 0:
loop = asyncio.get_running_loop()
executor = self._tx_executor
if (
self._connected
and self._serial
and self._serial.is_open
and self._led_count > 0
and executor is not None
):
try:
black = np.zeros((self._led_count, 3), dtype=np.uint8)
frame = self._build_frame(black, brightness=255)
@@ -89,8 +124,8 @@ class AdalightClient(LEDClient):
f"Adalight sending black frame: {self._port} "
f"({self._led_count} LEDs, {len(frame)} bytes)"
)
await asyncio.to_thread(self._serial.write, frame)
await asyncio.to_thread(self._serial.flush)
await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush)
logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}")
@@ -108,6 +143,9 @@ class AdalightClient(LEDClient):
except Exception as e:
logger.warning(f"Error closing serial port: {e}")
self._serial = None
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
logger.info(f"Adalight disconnected: {self._port}")
@property
@@ -125,12 +163,15 @@ class AdalightClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.is_connected:
executor = self._tx_executor
if not self.is_connected or executor is None:
return False
try:
frame = self._build_frame(pixels, brightness)
await asyncio.to_thread(self._serial.write, frame)
# ``run_in_executor`` skips the per-call ``contextvars.copy_context``
# / ``functools.partial`` overhead that ``asyncio.to_thread`` does.
await asyncio.get_running_loop().run_in_executor(executor, self._serial.write, frame)
return True
except Exception as e:
logger.error(f"Adalight send_pixels error: {e}")
@@ -141,17 +182,63 @@ class AdalightClient(LEDClient):
# Serial write is blocking — use async send_pixels path instead
return False
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16)
def _ensure_frame_buf(self, n_leds: int) -> None:
"""Lazily allocate / resize the wire-format frame buffer.
# Note: brightness already applied by processor loop (_cached_brightness)
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes
Header bytes are written once at the front; subsequent calls only
memcpy the pixel payload into the trailing slot.
"""
needed = self._header_len + n_leds * 3
if self._frame_buf is None or len(self._frame_buf) != needed:
buf = bytearray(needed)
buf[: self._header_len] = self._header
self._frame_buf = buf
self._frame_buf_n = n_leds
def _ensure_u8_scratch(self, n_leds: int) -> np.ndarray:
"""Pre-allocated (N, 3) uint8 scratch for non-conforming inputs."""
if self._u8_scratch is None or self._u8_scratch_n != n_leds:
self._u8_scratch = np.empty((n_leds, 3), dtype=np.uint8)
self._u8_scratch_n = n_leds
return self._u8_scratch
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame in the pre-allocated wire buffer.
The processor loop hands us a contiguous (N, 3) uint8 array with
brightness already applied (see ``_cached_brightness``), so the hot
path is one memcpy from the pixel buffer into the trailing slot of
``_frame_buf``. All other input shapes (lists of tuples, wrong
dtype, non-contiguous views) coerce into a pre-allocated uint8
scratch before the memcpy — still allocation-free in steady state.
"""
if isinstance(pixels, np.ndarray):
n_leds = pixels.shape[0]
if pixels.dtype == np.uint8 and pixels.flags["C_CONTIGUOUS"]:
# Hot path: input matches wire format exactly.
arr = pixels
else:
# Slow path: dtype mismatch or non-contiguous view. Coerce
# into a pre-allocated uint8 scratch. Wider integer dtypes
# are clamped to [0, 255] to match historical behaviour.
arr = self._ensure_u8_scratch(n_leds)
if pixels.dtype != np.uint8:
# Clamp wider integer dtypes to [0, 255] before the
# uint8 narrowing copy. This is the rare slow path —
# one extra allocation here is fine.
np.copyto(arr, np.clip(pixels, 0, 255), casting="unsafe")
else:
np.copyto(arr, pixels)
else:
# List/tuple input — rare path, only hit by tests/legacy callers.
arr = np.array(pixels, dtype=np.uint8)
n_leds = arr.shape[0]
self._ensure_frame_buf(n_leds)
# memcpy pixel bytes into the trailing slot of the pre-built buffer
view = memoryview(self._frame_buf)
view[self._header_len :] = memoryview(arr).cast("B")
return self._frame_buf
@classmethod
async def check_health(
+86 -114
View File
@@ -39,6 +39,9 @@ class DDPClient:
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
DDP_TYPE_RGB = 0x01
# Pre-built struct.Struct for the 10-byte DDP header (avoids per-call format parsing)
_HEADER_STRUCT = struct.Struct("!BBB B I H")
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""Initialize DDP client.
@@ -57,6 +60,10 @@ class DDPClient:
# Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None
self._rgbw_buf_n: int = 0
# Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None
self._send_view: Optional[memoryview] = None
async def connect(self):
"""Establish UDP connection."""
@@ -93,52 +100,52 @@ class DDPClient:
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
)
def _build_ddp_packet(
self,
rgb_data: bytes,
offset: int = 0,
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
def _ensure_send_buf(self, capacity: int) -> None:
"""Lazily allocate / grow the per-instance send buffer.
DDP packet format (10-byte header + data):
- Byte 0: Flags (VER1 | PUSH on last packet)
- Byte 1: Sequence number
- Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID
- Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data
Args:
rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns:
Complete DDP packet as bytes
``capacity`` is the largest packet we may emit (header + payload).
Once sized, the buffer is reused for every subsequent send so the
hot path stays allocation-free.
"""
flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB
source_id = 0x01
data_len = len(rgb_data)
buf = self._send_buf
if buf is None or len(buf) < capacity:
self._send_buf = bytearray(capacity)
self._send_view = memoryview(self._send_buf)
# Build header (10 bytes)
header = struct.pack(
"!BBB B I H", # Network byte order (big-endian)
flags, # Flags
sequence, # Sequence
data_type, # Data type
source_id, # Source/Destination
offset, # Data offset (4 bytes)
data_len, # Data length (2 bytes)
def _emit_packet(
self,
payload: memoryview,
offset: int,
sequence: int,
push: bool,
) -> None:
"""Pack header + payload into the pre-allocated send buffer and emit.
DDP packet layout (10-byte header):
[0] Flags (VER1 | PUSH on last)
[1] Sequence
[2] Data type (0x01 = RGB)
[3] Source/Destination ID
[4-7] Data offset (big-endian)
[8-9] Data length (big-endian)
[10+] Pixel data
"""
flags = self.DDP_FLAGS_VER1 | (self.DDP_FLAGS_PUSH if push else 0)
data_len = len(payload)
self._ensure_send_buf(10 + data_len)
buf = self._send_buf
view = self._send_view
# Fill header into pre-allocated buffer (no allocation)
self._HEADER_STRUCT.pack_into(
buf, 0, flags, sequence, self.DDP_TYPE_RGB, 0x01, offset, data_len
)
return header + rgb_data
# Copy payload bytes into buffer (single memcpy)
view[10 : 10 + data_len] = payload
# asyncio's selector_datagram_transport.sendto fast-path calls
# socket.sendto(data) which accepts a buffer-like; it only copies to
# bytes when the OS send buffer is full and the datagram must be
# queued. So passing a memoryview is safe and avoids `bytes(...)`.
self._transport.sendto(view[: 10 + data_len])
def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray:
"""Apply per-bus color order reordering using numpy fancy indexing.
@@ -168,13 +175,39 @@ class DDPClient:
return result
def _send_buffer(self, payload_view: memoryview, bpp: int, max_packet_size: int) -> int:
"""Chunk and emit a contiguous payload via DDP. Returns packet count.
``payload_view`` is a 1-D bytes-like view; the caller guarantees its
length is a multiple of ``bpp``. Each emitted packet is sized to a
whole number of pixels so RGB channels never split across packets.
"""
total_bytes = len(payload_view)
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
if bytes_per_packet <= 0:
bytes_per_packet = bpp # degenerate guard
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
for i in range(num_packets):
start = i * bytes_per_packet
end = total_bytes if (i == num_packets - 1) else (start + bytes_per_packet)
self._sequence = (self._sequence + 1) % 256
self._emit_packet(
payload_view[start:end],
offset=start,
sequence=self._sequence,
push=(i == num_packets - 1),
)
return num_packets
async def send_pixels(
self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400
) -> bool:
"""Send pixel data via DDP.
Args:
pixels: List of (R, G, B) tuples
pixels: List of (R, G, B) tuples or an (N, 3) uint8 numpy array
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
Returns:
@@ -187,65 +220,17 @@ class DDPClient:
raise RuntimeError("DDP client not connected")
try:
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
# Accept numpy arrays directly to avoid per-pixel Python loop
bpp = 4 if self.rgbw else 3 # bytes per pixel
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if self.rgbw:
n = pixel_array.shape[0]
if n != self._rgbw_buf_n:
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
self._rgbw_buf_n = n
self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
total_bytes = len(pixel_bytes)
# Align payload to full pixels (multiple of bpp) to avoid splitting
# a pixel's channels across packets
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
# Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
logger.debug(
f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) "
f"in {num_packets} packet(s) to {self.host}:{self.port}"
)
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Set PUSH flag on the last packet to signal frame completion
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
)
self.send_pixels_numpy(pixel_array, max_packet_size=max_packet_size)
return True
except Exception as e:
logger.error(f"Failed to send DDP pixels: {e}")
logger.error("Failed to send DDP pixels: %s", e)
raise RuntimeError(f"DDP send failed: {e}")
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool:
@@ -270,28 +255,15 @@ class DDPClient:
self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
bpp = 4 if self.rgbw else 3
total_bytes = len(pixel_bytes)
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
self._sequence = (self._sequence + 1) % 256
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
# Get a 1-D bytes view of the pixel buffer with no allocation when
# the array is already C-contiguous (the common case).
if not pixel_array.flags["C_CONTIGUOUS"]:
pixel_array = np.ascontiguousarray(pixel_array)
# ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte
# view; total length == nbytes.
payload_view = memoryview(pixel_array).cast("B")
self._send_buffer(payload_view, bpp, max_packet_size)
return True
async def __aenter__(self):
@@ -0,0 +1,273 @@
"""Background discovery watcher — long-running mDNS browser + serial port poller.
Existing per-target health monitoring (``DeviceHealthMixin``) already fires
``device_health_changed`` events when *configured* devices flip online/offline.
This module is the complementary half: it watches for *new* devices appearing
on the LAN/USB (and old discovered-but-never-configured ones disappearing) and
emits ``device_discovered`` / ``device_lost`` events on the same event bus.
Design notes
------------
- The mDNS browser is kept alive for the process lifetime (one ``AsyncZeroconf``
+ ``AsyncServiceBrowser``), which is the entire point of "background discovery".
The on-demand scan in ``WLEDDeviceProvider.discover`` is unchanged — that one
spins up its own short-lived browser for the Add Device modal.
- Serial-port hotplug has no equivalent of mDNS push, so we poll
:func:`list_serial_ports` every 10 s. Cheap on desktop (one Windows API call),
no-op on Android (handled separately by Kotlin USB receivers).
- Already-configured devices (matched by URL or MAC against ``device_store``)
are intentionally suppressed from the discovery stream — those are covered by
the health-monitor's online/offline events and would otherwise notify twice
per device on startup.
The watcher is purely an event source; pref-driven snack/OS-toast routing
happens client-side in ``features/notifications-watcher.ts``.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
if TYPE_CHECKING:
from ledgrab.storage.device_store import DeviceStore
logger = get_logger(__name__)
# Poll interval for serial-port enumeration. Cheap on desktop; skipped on Android.
_SERIAL_POLL_INTERVAL_SEC = 10.0
@dataclass(frozen=True)
class _DiscoveredEntry:
"""A device the watcher has seen at least once.
Two snapshots are compared each cycle (mDNS by service name, serial by
device path) to detect appearances vs. disappearances; the URL is what
we cross-reference against ``device_store`` to know if the device is
already configured.
"""
key: str
url: str
name: str
device_type: str # "wled" | "serial"
FireEvent = Callable[[dict], None]
class DiscoveryWatcher:
"""Continuously scan for new WLED/serial devices and emit events."""
def __init__(self, device_store: "DeviceStore", fire_event: FireEvent) -> None:
self._device_store = device_store
self._fire_event = fire_event
self._aiozc: Optional[AsyncZeroconf] = None
self._browser: Optional[AsyncServiceBrowser] = None
self._serial_task: Optional[asyncio.Task] = None
self._running = False
self._started_at: float = 0.0
# service-name -> entry. mDNS state-change callback runs on the
# asyncio thread so no lock is needed; Python attr writes are atomic.
self._wled_seen: Dict[str, _DiscoveredEntry] = {}
# device-path -> entry. Only the serial poller mutates this.
self._serial_seen: Dict[str, _DiscoveredEntry] = {}
# --- lifecycle --------------------------------------------------------
async def start(self) -> None:
if self._running:
return
self._running = True
self._started_at = time.monotonic()
# mDNS browser — kept alive for the whole process. The handler is sync
# (zeroconf calls it via call_soon on our loop), but resolves run in a
# short-lived task to avoid blocking the dispatcher.
try:
self._aiozc = AsyncZeroconf()
self._browser = AsyncServiceBrowser(
self._aiozc.zeroconf,
WLED_MDNS_TYPE,
handlers=[self._on_wled_state_change],
)
logger.info("Discovery watcher: mDNS browser started for %s", WLED_MDNS_TYPE)
except Exception as e:
# Don't let a zeroconf failure (firewall, multiple-host, etc.)
# prevent the rest of the server from coming up.
logger.error("Discovery watcher: failed to start mDNS browser: %s", e)
self._aiozc = None
self._browser = None
# Serial poller — only on desktop. On Android, USB hotplug is delivered
# through Kotlin receivers, not by polling pyserial.
if not is_android():
self._serial_task = asyncio.create_task(self._serial_poll_loop())
async def stop(self) -> None:
self._running = False
if self._serial_task is not None:
self._serial_task.cancel()
try:
await self._serial_task
except (asyncio.CancelledError, Exception):
pass
self._serial_task = None
if self._browser is not None:
try:
await self._browser.async_cancel()
except Exception as e:
logger.debug("Discovery watcher: browser cancel error: %s", e)
self._browser = None
if self._aiozc is not None:
try:
await self._aiozc.async_close()
except Exception as e:
logger.debug("Discovery watcher: zeroconf close error: %s", e)
self._aiozc = None
logger.info("Discovery watcher stopped")
# --- mDNS -------------------------------------------------------------
def _on_wled_state_change(self, **kwargs) -> None:
"""zeroconf state-change handler. Runs on the asyncio thread."""
state_change = kwargs.get("state_change")
service_type = kwargs.get("service_type", "")
name = kwargs.get("name", "")
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
# Resolve in a task — async_request blocks the handler if awaited
# synchronously and we don't want to stall mDNS dispatch.
asyncio.create_task(self._resolve_wled(service_type, name))
elif state_change == ServiceStateChange.Removed:
entry = self._wled_seen.pop(name, None)
if entry is not None and not self._is_configured(entry.url):
self._emit("device_lost", entry)
async def _resolve_wled(self, service_type: str, name: str) -> None:
if self._aiozc is None:
return
info = AsyncServiceInfo(service_type, name)
try:
await info.async_request(self._aiozc.zeroconf, timeout=2000)
except Exception as e:
logger.debug("Discovery watcher: resolve failed for %s: %s", name, e)
return
addrs = info.parsed_addresses()
if not addrs:
return
ip = addrs[0]
port = info.port or 80
url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
service_name = name.replace(f".{service_type}", "")
entry = _DiscoveredEntry(
key=name,
url=url,
name=service_name,
device_type="wled",
)
first_sight = name not in self._wled_seen
self._wled_seen[name] = entry
if first_sight and not self._is_configured(url):
self._emit("device_discovered", entry)
# --- serial -----------------------------------------------------------
async def _serial_poll_loop(self) -> None:
"""Detect serial-port appearances/disappearances on a fixed interval."""
try:
# Seed without notifying — ports already plugged in when the server
# starts shouldn't generate "new device" toasts on every boot.
for port in list_serial_ports():
url = port.device
self._serial_seen[url] = _DiscoveredEntry(
key=url,
url=url,
name=port.description,
device_type="serial",
)
while self._running:
await asyncio.sleep(_SERIAL_POLL_INTERVAL_SEC)
if not self._running:
break
self._poll_serial_once()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("Discovery watcher: serial loop crashed: %s", e)
def _poll_serial_once(self) -> None:
try:
current = {p.device: p for p in list_serial_ports()}
except Exception as e:
logger.debug("Discovery watcher: serial enumeration failed: %s", e)
return
# Appeared
for device, port in current.items():
if device in self._serial_seen:
continue
entry = _DiscoveredEntry(
key=device,
url=device,
name=port.description,
device_type="serial",
)
self._serial_seen[device] = entry
if not self._is_configured(device):
self._emit("device_discovered", entry)
# Disappeared
for device in list(self._serial_seen.keys()):
if device in current:
continue
entry = self._serial_seen.pop(device)
if not self._is_configured(entry.url):
self._emit("device_lost", entry)
# --- helpers ----------------------------------------------------------
def _is_configured(self, url: str) -> bool:
"""True when the URL matches a device already in the user's store."""
try:
for device in self._device_store.get_all_devices():
if device.url and device.url.rstrip("/") == url.rstrip("/"):
return True
except Exception as e:
logger.debug("Discovery watcher: device store lookup failed: %s", e)
return False
def _emit(self, event_type: str, entry: _DiscoveredEntry) -> None:
try:
self._fire_event(
{
"type": event_type,
"device_type": entry.device_type,
"url": entry.url,
"name": entry.name,
}
)
except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e)
@@ -148,23 +148,37 @@ class ApiInputColorStripStream(ColorStripStream):
"""Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed.
order (last wins on overlap).
``start`` and ``length`` are optional: ``start`` defaults to 0,
``length`` defaults to ``led_count - start`` (i.e. the remainder of
the strip). The buffer is only auto-grown for segments that supply
an explicit ``length`` extending past the current end — implicit
"to the end" segments adapt to whatever the current strip size is.
Args:
segments: list of dicts with keys:
start (int) starting LED index
length (int) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
start (int, optional) starting LED index (default 0)
length (int, optional) number of LEDs in segment
(default = led_count - start)
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
"""
# Compute required buffer size from all segments
max_index = max(seg["start"] + seg["length"] for seg in segments)
# Compute required buffer size from segments that supply an explicit
# length. Segments without a length take the strip as-is and so do
# not trigger growth.
explicit_max = 0
for seg in segments:
seg_start = seg.get("start") or 0
seg_len = seg.get("length")
if seg_len is not None:
explicit_max = max(explicit_max, seg_start + seg_len)
with self._lock:
# Auto-grow buffer if needed
if max_index > self._led_count:
self._ensure_capacity(max_index)
# Auto-grow buffer if any explicit segment extends past current end
if explicit_max > self._led_count:
self._ensure_capacity(explicit_max)
# Start from current buffer (or fallback if timed out)
if self._timed_out:
@@ -173,8 +187,12 @@ class ApiInputColorStripStream(ColorStripStream):
buf = self._colors.copy()
for seg in segments:
start = seg["start"]
length = seg["length"]
seg_start = seg.get("start")
start = 0 if seg_start is None else seg_start
seg_len = seg.get("length")
length = max(0, self._led_count - start) if seg_len is None else seg_len
if length <= 0:
continue
mode = seg["mode"]
end = start + length
@@ -6,7 +6,6 @@ via the shim module ``color_strip_stream.py``.
"""
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
from .cycle import ColorCycleColorStripStream
from .gradient import GradientColorStripStream
from .helpers import _compute_gradient_colors
from .picture import PictureColorStripStream
@@ -16,7 +15,6 @@ __all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -45,6 +45,19 @@ class ColorStripStream(ABC):
def target_fps(self) -> int:
"""Target processing rate."""
@property
def actual_fps(self) -> Optional[float]:
"""Measured rate of *new* frames the stream is delivering, or ``None``.
Only streams backed by an external capture (screen, audio device, API
push) implement this — the value answers "is the upstream actually
keeping up?". Synthetic streams (gradient/static/cycle/effect/...)
always tick at their `target_fps` by construction, so reporting an
actual rate would just duplicate `target_fps` without diagnostic
value; they keep the default ``None``.
"""
return None
@property
@abstractmethod
def led_count(self) -> int:
@@ -1,185 +0,0 @@
"""Color cycle stream — smoothly cycles through user-defined colors."""
import threading
import time
from typing import Optional
import numpy as np
from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer
from .base import ColorStripStream
logger = get_logger(__name__)
class ColorCycleColorStripStream(ColorStripStream):
"""Color strip stream that smoothly cycles through a user-defined color list.
All LEDs receive the same solid color at any moment, continuously interpolating
between the configured colors in a loop.
LED count auto-sizes from the connected device when led_count == 0 in
the source config; configure(device_led_count) is called by
WledTargetProcessor on start.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
raw = source.colors if isinstance(source.colors, list) else []
default = [
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc > 0 else 1
self._rebuild_colors()
def _rebuild_colors(self) -> None:
pixel = np.array(self._color_list[0], dtype=np.uint8)
colors = np.tile(pixel, (self._led_count, 1))
with self._colors_lock:
self._colors = colors
def configure(self, device_led_count: int) -> None:
"""Size to device LED count when led_count was 0 (auto-size)."""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._rebuild_colors()
logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-color-cycle",
daemon=True,
)
self._thread.start()
logger.info(
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning(
"ColorCycleColorStripStream animate thread did not terminate within 5s"
)
self._thread = None
logger.info("ColorCycleColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from ledgrab.storage.color_strip_source import ColorCycleColorStripSource
if isinstance(source, ColorCycleColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("ColorCycleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at target fps.
Uses double-buffered output arrays to avoid per-frame allocations.
"""
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
limiter = FrameLimiter(self._fps)
try:
with high_resolution_timer():
while self._running:
limiter.begin()
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
color_list = self._color_list
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = 1.0
t = wall_start
n = self._led_count
num = len(color_list)
if num >= 2:
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
# 0.05 factor → one full cycle every 20s at speed=1.0
cycle_pos = (speed * t * 0.05) % 1.0
seg = cycle_pos * num
idx = int(seg) % num
t_i = seg - int(seg)
c1 = color_list[idx]
c2 = color_list[(idx + 1) % num]
buf[:] = (
min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"ColorCycleColorStripStream animation error: {e}")
limiter.wait(frame_time)
except Exception as e:
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
@@ -2,6 +2,7 @@
import threading
import time
from collections import deque
from typing import Optional
import numpy as np
@@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream):
self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {}
# Rolling 1s window of timestamps for *new* frames received from
# the live stream. `len(...)` is the per-second frame rate the
# picture pipeline is actually consuming — diverges from
# `target_fps` when the underlying screen capture stalls (heavy
# GPU load, occluded window, DXGI desktop switch, etc.). Reads
# from another thread see a stale length at worst; deque ops are
# atomic under the GIL so no lock is needed.
self._new_frame_timestamps: deque[float] = deque(maxlen=180)
@property
def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
@@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Measured new-frame rate over the last 1 second.
Returns the count of distinct frames the picture loop accepted in
the trailing 1s window. ``None`` until the loop has run (no
meaningful number to report yet).
"""
ts_dq = self._new_frame_timestamps
if not ts_dq:
return None
# Stale-tolerant read: producer may pop while we iterate, but we
# only look at the snapshot length and the leftmost timestamp.
now = time.perf_counter()
# If the stream has gone idle (no new frames for >1s) the deque
# still holds samples until the loop next ticks; report 0 so the
# spark drops to the floor instead of pinning at the last rate.
try:
oldest = ts_dq[0]
except IndexError:
return None
if now - oldest > 1.5:
return 0.0
return float(len(ts_dq))
@property
def led_count(self) -> int:
return self._led_count
@@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None
self._latest_colors = None
self._previous_colors = None
self._new_frame_timestamps.clear()
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream):
cached_frame = frame
t0 = time.perf_counter()
# Record the new frame in the rolling 1s window
# used by `actual_fps`. Pop entries older than
# 1s so `len()` reads as frames-per-second.
ts_dq = self._new_frame_timestamps
ts_dq.append(t0)
cutoff = t0 - 1.0
while ts_dq and ts_dq[0] < cutoff:
ts_dq.popleft()
calibration = self._calibration
mapper = self._pixel_mapper
@@ -73,7 +73,14 @@ class StaticColorStripStream(ColorStripStream):
@property
def is_animated(self) -> bool:
anim = self._animation
return bool(anim and anim.get("enabled"))
if anim and anim.get("enabled"):
return True
return self._is_color_bound()
def _is_color_bound(self) -> bool:
"""True when the `color` property is driven by a ValueStream."""
vs = self._value_streams
return bool(vs and vs.get("color"))
@property
def led_count(self) -> int:
@@ -243,10 +250,28 @@ class StaticColorStripStream(ColorStripStream):
if colors is not None:
with self._colors_lock:
self._colors = colors
elif self._is_color_bound():
# No animation, but color is driven by a ValueStream —
# poll and forward live color updates so the bound
# source is honoured (otherwise LEDs stay stuck on
# the static fallback).
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
buf[:] = self.resolve_color("color", self._source_color)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}")
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
if (anim and anim.get("enabled")) or self._is_color_bound():
sleep_target = frame_time
else:
sleep_target = 0.25
limiter.wait(sleep_target)
except Exception as e:
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
@@ -6,7 +6,6 @@ import path ``from ledgrab.core.processing.color_strip_stream import X``.
"""
from ledgrab.core.processing.color_strip import ( # noqa: F401
ColorCycleColorStripStream,
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
@@ -20,7 +19,6 @@ __all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -3,7 +3,7 @@
PictureColorStripStreams (expensive screen capture) are shared across multiple
consumers via reference counting — processing runs once, not once per target.
Count-dependent streams (static, gradient, color cycle, effect) are NOT shared.
Count-dependent streams (static, gradient, effect) are NOT shared.
Each consumer gets its own instance so it can configure an independent LED count
without interfering with other targets.
"""
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from typing import Dict, Optional
from ledgrab.core.processing.color_strip_stream import (
ColorCycleColorStripStream,
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
@@ -34,7 +33,6 @@ logger = get_logger(__name__)
_SIMPLE_STREAM_MAP = {
"static": StaticColorStripStream,
"gradient": GradientColorStripStream,
"color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
@@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Aggregate measured capture rate across capture-backed sub-streams.
Sums `actual_fps` from each sub-stream that reports one (i.e.
capture-backed layers like screen/audio captures). Returns
``None`` when no sub-stream measures capture — keeps synthetic-
only composites out of the "Total Capture FPS" cell instead of
contributing a 0.
"""
with self._sub_lock:
subs = list(self._sub_streams.values())
total = 0.0
any_reporting = False
for _src_id, _consumer_id, stream in subs:
try:
v = getattr(stream, "actual_fps", None)
except Exception:
v = None
if isinstance(v, (int, float)):
total += float(v)
any_reporting = True
return total if any_reporting else None
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@@ -0,0 +1,87 @@
"""Global daylight cycle preferences.
A single timezone applies to every daylight value source / color strip
source on the server, so it lives in the key/value settings table rather
than on each entity. The daylight streams read it on every wall-clock
sample (cheap dict lookup with a short cache window) so changing it in
the UI takes effect within ~1 second.
"""
from __future__ import annotations
import threading
import time
from typing import Optional
from ledgrab.utils import get_logger
logger = get_logger(__name__)
DAYLIGHT_TIMEZONE_KEY = "daylight_timezone"
_CACHE_TTL_SECONDS = 1.0
_lock = threading.Lock()
_cached_tz: str = ""
_cached_at: float = 0.0
def _read_from_db() -> str:
"""Read the persisted timezone from the settings table.
Returns an empty string when unset, the table is unavailable, or
the stored value is corrupt — empty means "use system local time".
"""
try:
from ledgrab.api.dependencies import get_database
raw = get_database().get_setting(DAYLIGHT_TIMEZONE_KEY)
except Exception as e: # pragma: no cover — DB not initialised yet, e.g. in tests
logger.debug("daylight timezone DB read failed: %s", e)
return ""
if not isinstance(raw, dict):
return ""
value = raw.get("value")
return str(value) if isinstance(value, str) else ""
def get_daylight_timezone() -> str:
"""Return the configured global daylight timezone (cached briefly)."""
global _cached_tz, _cached_at
now = time.monotonic()
with _lock:
if now - _cached_at < _CACHE_TTL_SECONDS:
return _cached_tz
fresh = _read_from_db()
with _lock:
_cached_tz = fresh
_cached_at = now
return fresh
def set_daylight_timezone(tz: Optional[str]) -> str:
"""Persist the global daylight timezone and refresh the cache.
Returns the canonicalised stored value (empty string for None / blank).
"""
canonical = str(tz or "").strip()
try:
from ledgrab.api.dependencies import get_database
get_database().set_setting(DAYLIGHT_TIMEZONE_KEY, {"value": canonical})
except Exception as e:
logger.warning("Failed to persist daylight timezone: %s", e)
global _cached_tz, _cached_at
with _lock:
_cached_tz = canonical
_cached_at = time.monotonic()
return canonical
def invalidate_cache() -> None:
"""Force the next ``get_daylight_timezone`` call to re-read from DB."""
global _cached_at
with _lock:
_cached_at = 0.0
@@ -22,8 +22,33 @@ from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
logger = get_logger(__name__)
def _now_in_tz(tz_name: str) -> datetime.datetime:
"""Return current wall-clock time in the named IANA timezone.
Empty string means "use system local time". Unknown timezones fall
back to local time and log a warning once per unknown name.
"""
if not tz_name or ZoneInfo is None:
return datetime.datetime.now()
try:
return datetime.datetime.now(ZoneInfo(tz_name))
except ZoneInfoNotFoundError:
logger.warning(f"Unknown daylight timezone '{tz_name}' — falling back to system local")
return datetime.datetime.now()
# ── Daylight color table ────────────────────────────────────────────────
#
# Canonical hour control points (024) → RGB. Designed for a default
@@ -62,13 +87,19 @@ _daylight_lut: Optional[np.ndarray] = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
"""Return (sunrise_hour, sunset_hour) in local solar time.
def _compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
Polar day and polar night are clamped to visible ranges.
"""
@@ -79,28 +110,48 @@ def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) ->
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets
sunrise = 3.0
sunset = 21.0
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises
sunrise = 12.0
sunset = 12.0
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
lon_offset = longitude / 15.0
solar_noon = 12.0 - lon_offset
sunrise = solar_noon - ha_hours
sunset = solar_noon + ha_hours
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to sane ranges
sunrise = max(3.0, min(10.0, sunrise))
sunset = max(14.0, min(21.0, sunset))
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
@@ -198,9 +249,11 @@ class DaylightColorStripStream(ColorStripStream):
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
def _get_lut_for_day(self, day_of_year: int, utc_offset_hours: float = 0.0) -> np.ndarray:
"""Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
sunrise, sunset = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key)
@@ -304,10 +357,16 @@ class DaylightColorStripStream(ColorStripStream):
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
from ledgrab.core.processing.daylight_settings import (
get_daylight_timezone,
)
tz_name = get_daylight_timezone()
if self._use_real_time:
now = datetime.datetime.now()
now = _now_in_tz(tz_name)
day_of_year = now.timetuple().tm_yday
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
utc_offset_hours = _utc_offset_hours_for(tz_name, now)
else:
# Simulated: speed=1.0 → full 24h in 240s.
# Use summer solstice (day 172) for maximum day length.
@@ -315,8 +374,9 @@ class DaylightColorStripStream(ColorStripStream):
cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0
utc_offset_hours = _utc_offset_hours_for(tz_name)
lut = self._get_lut_for_day(day_of_year)
lut = self._get_lut_for_day(day_of_year, utc_offset_hours)
idx = int(minute_of_day) % 1440
color = lut[idx]
buf[:] = color
@@ -224,6 +224,7 @@ class HALightTargetProcessor(TargetProcessor):
"update_rate": self._update_rate,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"fps_capture": self._update_rate if self._is_running else None,
"uptime_seconds": uptime,
"entity_colors": entity_colors,
}
@@ -75,6 +75,16 @@ class MetricsHistory:
self._system: deque = deque(maxlen=MAX_SAMPLES)
self._targets: Dict[str, deque] = {}
self._task: Optional[asyncio.Task] = None
# Baselines for converting cumulative `errors_count` /
# `frames_skipped` into per-second rates inside the system ring
# buffer. None until the first sample arrives so we don't
# synthesize a fake initial spike from "0 → live count".
self._prev_total_errors: Optional[int] = None
self._prev_total_skipped: Optional[int] = None
# Same shape, but for the network throughput counter. Reset to
# None when the cumulative sum drops (target stopped, counter
# reset) so we never emit a negative rate.
self._prev_total_bytes_sent: Optional[int] = None
async def start(self):
"""Start the background sampling loop."""
@@ -110,7 +120,6 @@ class MetricsHistory:
"""Collect one snapshot of system and target metrics."""
# System metrics (blocking psutil/nvml calls in thread pool)
sys_snap = await asyncio.to_thread(_collect_system_snapshot)
self._system.append(sys_snap)
# Per-target metrics from processor states
try:
@@ -121,22 +130,151 @@ class MetricsHistory:
now = datetime.now(timezone.utc).isoformat()
active_ids = set()
# Aggregates across running targets — mirrors the dashboard's
# frontend computation so the FPS / Capture FPS / Errors cells
# can seed their sparklines from this ring buffer and survive
# a page reload, the same way CPU / RAM already do.
total_fps = 0.0
total_capture_fps = 0.0
total_capture_fps_actual = 0.0
capture_actual_count = 0
total_fps_target = 0.0
total_errors_count = 0
total_frames_skipped = 0
running_count = 0
# Network / send-timing aggregates across running targets.
# `send_timing_*` reads "is the LED transport keeping up?" — a
# leading indicator of network congestion that fires before
# frames actually start dropping.
total_bytes_sent = 0
send_timing_max_ms = 0.0
send_timing_sum_ms = 0.0
send_timing_count = 0
for target_id, state in all_states.items():
active_ids.add(target_id)
if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
if state.get("processing"):
running_count += 1
fps_actual = state.get("fps_actual")
if isinstance(fps_actual, (int, float)) and fps_actual > 0:
total_fps += float(fps_actual)
fps_capture = state.get("fps_capture")
if isinstance(fps_capture, (int, float)) and fps_capture > 0:
total_capture_fps += float(fps_capture)
fps_capture_actual = state.get("fps_capture_actual")
# `None` means the stream type doesn't measure capture
# (synthetic streams). Counted separately so the cell
# can read "0 of 0" vs "0 of N stalled".
if isinstance(fps_capture_actual, (int, float)):
total_capture_fps_actual += float(fps_capture_actual)
capture_actual_count += 1
fps_target = state.get("fps_target")
if isinstance(fps_target, (int, float)) and fps_target > 0:
total_fps_target += float(fps_target)
errors_count = state.get("errors_count")
if isinstance(errors_count, (int, float)) and errors_count > 0:
total_errors_count += int(errors_count)
frames_skipped = state.get("frames_skipped")
if isinstance(frames_skipped, (int, float)) and frames_skipped > 0:
total_frames_skipped += int(frames_skipped)
bytes_sent = state.get("bytes_sent")
if isinstance(bytes_sent, (int, float)) and bytes_sent > 0:
total_bytes_sent += int(bytes_sent)
send_timing = state.get("timing_send_ms")
if isinstance(send_timing, (int, float)) and send_timing >= 0:
send_timing_sum_ms += float(send_timing)
send_timing_count += 1
if send_timing > send_timing_max_ms:
send_timing_max_ms = float(send_timing)
self._targets[target_id].append(
{
"t": now,
"fps": state.get("fps_actual"),
"fps": fps_actual,
"fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"),
"fps_target": fps_target,
"timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0),
}
)
# Convert the cumulative error/skipped totals into per-second
# rates. Guard against the first sample (no previous baseline)
# and against counter resets when a target stops or restarts
# (delta < 0 → treat as 0).
errors_per_sec = 0.0
skipped_per_sec = 0.0
bytes_per_sec = 0.0
if self._prev_total_errors is not None:
delta = max(0, total_errors_count - self._prev_total_errors)
errors_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_skipped is not None:
delta = max(0, total_frames_skipped - self._prev_total_skipped)
skipped_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_bytes_sent is not None:
delta_b = max(0, total_bytes_sent - self._prev_total_bytes_sent)
bytes_per_sec = delta_b / SAMPLE_INTERVAL
self._prev_total_errors = total_errors_count
self._prev_total_skipped = total_frames_skipped
self._prev_total_bytes_sent = total_bytes_sent
# Device latency aggregates — pulled from the manager's
# device-health view rather than re-deriving from per-target
# state, so devices that are shared by multiple targets only
# count once.
device_latency_avg_ms: Optional[float] = None
device_latency_max_ms: Optional[float] = None
device_online_count = 0
device_total_count = 0
try:
health_dicts = self._manager.get_all_device_health_dicts()
except Exception as e:
logger.error("Failed to get device health: %s", e)
health_dicts = {}
latency_sum = 0.0
latency_n = 0
latency_max = 0.0
for _did, h in health_dicts.items():
device_total_count += 1
if h.get("device_online"):
device_online_count += 1
lat = h.get("device_latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
latency_sum += float(lat)
latency_n += 1
if lat > latency_max:
latency_max = float(lat)
if latency_n > 0:
device_latency_avg_ms = round(latency_sum / latency_n, 1)
device_latency_max_ms = round(latency_max, 1)
sys_snap["total_fps"] = round(total_fps, 1)
sys_snap["total_capture_fps"] = round(total_capture_fps, 1)
sys_snap["total_capture_fps_actual"] = round(total_capture_fps_actual, 1)
sys_snap["capture_actual_count"] = capture_actual_count
sys_snap["total_fps_target"] = round(total_fps_target, 1)
sys_snap["total_errors_count"] = total_errors_count
sys_snap["total_frames_skipped"] = total_frames_skipped
sys_snap["errors_per_sec"] = round(errors_per_sec, 3)
sys_snap["skipped_per_sec"] = round(skipped_per_sec, 3)
sys_snap["running_count"] = running_count
sys_snap["total_bytes_sent"] = total_bytes_sent
sys_snap["bytes_per_sec"] = round(bytes_per_sec, 1)
sys_snap["send_timing_avg_ms"] = (
round(send_timing_sum_ms / send_timing_count, 2) if send_timing_count > 0 else 0.0
)
sys_snap["send_timing_max_ms"] = round(send_timing_max_ms, 2)
sys_snap["send_timing_count"] = send_timing_count
sys_snap["device_latency_avg_ms"] = device_latency_avg_ms
sys_snap["device_latency_max_ms"] = device_latency_max_ms
sys_snap["device_online_count"] = device_online_count
sys_snap["device_total_count"] = device_total_count
self._system.append(sys_snap)
# Prune deques for targets no longer registered
for tid in list(self._targets.keys()):
if tid not in active_ids:
@@ -167,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
gradient_store=deps.gradient_store,
event_bus=deps.game_event_bus,
audio_processing_template_store=deps.audio_processing_template_store,
sync_clock_manager=deps.sync_clock_manager,
)
if deps.value_source_store
else None
@@ -69,6 +69,13 @@ class ProcessingMetrics:
# Streaming liveness (HTTP probe during DDP)
device_streaming_reachable: Optional[bool] = None
fps_effective: int = 0
# Cumulative LED-payload bytes sent to the device. Aggregated across
# all running targets in MetricsHistory to derive a per-second
# network throughput sparkline. Counts the color-array payload only;
# protocol overhead (DDP/UDP/IP headers) is sub-5 % for any
# non-trivial LED count and is intentionally ignored to keep the
# counter cheap (`np.ndarray.nbytes`, no per-frame allocation).
bytes_sent: int = 0
@dataclass
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.value_source import ValueSource
from ledgrab.storage.value_source_store import ValueSourceStore
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
speed: float = 1.0,
use_real_time: bool = False,
latitude: float = 50.0,
longitude: float = 0.0,
min_value: float = 0.0,
max_value: float = 1.0,
):
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
self._lut = _get_daylight_lut()
self._default_lut = _get_daylight_lut()
self._speed = speed
self._use_real_time = use_real_time
self._latitude = latitude
self._longitude = longitude
self._min = min_value
self._max = max_value
self._start_time = time.perf_counter()
# Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream
self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {}
def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray:
if day_of_year is None:
return self._default_lut
from ledgrab.core.processing.daylight_stream import (
_build_lut_for_solar_times,
_compute_solar_times,
)
sr, ss = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
key = (int(round(sr * 60)), int(round(ss * 60)))
lut = self._lut_cache.get(key)
if lut is None:
lut = _build_lut_for_solar_times(sr, ss)
if len(self._lut_cache) > 8:
self._lut_cache.clear()
self._lut_cache[key] = lut
return lut
def get_value(self) -> float:
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
tz_name = get_daylight_timezone()
if self._use_real_time:
now = datetime.now()
now = _now_in_tz(tz_name)
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
else:
t_elapsed = time.perf_counter() - self._start_time
cycle_seconds = 240.0 / max(self._speed, 0.01)
phase = (t_elapsed % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0
lut = self._default_lut
idx = int(minute_of_day) % 1440
r, g, b = self._lut[idx]
r, g, b = lut[idx]
# BT.601 luminance → 0..1
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
self._speed = source.speed
self._use_real_time = source.use_real_time
self._latitude = source.latitude
self._longitude = source.longitude
self._min = source.min_value
self._max = source.max_value
self._lut_cache.clear()
# ---------------------------------------------------------------------------
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
)
class AnimatedColorValueStream(ValueStream):
"""Cycles through a list of colors over time."""
def _ease_color_frac(t: float, easing: str) -> float:
"""Remap a 0..1 segment fraction through a named easing curve.
def __init__(self, colors, speed=10.0, easing="linear"):
Unknown names fall back to linear so older configs and forward-compat
payloads keep working.
"""
if easing == "ease_in":
return t * t * t
if easing == "ease_out":
u = 1.0 - t
return 1.0 - u * u * u
if easing == "ease_in_out":
return t * t * (3.0 - 2.0 * t)
if easing == "sine":
return 0.5 - 0.5 * math.cos(math.pi * t)
return t
class AnimatedColorValueStream(ValueStream):
"""Cycles through a list of colors over time.
When a ``clock`` runtime is provided, animation is driven by the
clock's pause-aware elapsed time and speed multiplier so multiple
streams sharing the same clock stay in lockstep. When no clock is
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
"""
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
self._colors = [
(int(c[0]), int(c[1]), int(c[2]))
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
self._speed = max(0.01, float(speed))
self._easing = easing
self._start_time = 0.0
self._clock = clock
# Last frame state — held while the clock is paused so get_color()
# returns a stable color instead of jumping.
self._last_phase = 0.0
def start(self) -> None:
self._start_time = time.monotonic()
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
self._clock = clock
def get_value(self) -> float:
r, g, b = self.get_color()
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
def get_color(self) -> tuple:
elapsed = time.monotonic() - self._start_time
cycle_time = 60.0 / self._speed
clock = self._clock
n = len(self._colors)
if clock is not None:
# Clock provides real elapsed seconds (pause-aware) and a speed
# multiplier. We treat self._speed as the base cpm and apply the
# clock's speed on top, matching the convention used by CSS
# animation streams.
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
if not clock.is_running:
phase = self._last_phase
else:
elapsed = clock.get_time()
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
else:
elapsed = time.monotonic() - self._start_time
cycle_time = 60.0 / self._speed
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
if self._easing == "step":
idx = int((elapsed / cycle_time * n) % n)
return self._colors[idx]
phase = (elapsed / cycle_time * n) % n
return self._colors[int(phase) % n]
idx = int(phase)
frac = phase - idx
frac = _ease_color_frac(phase - idx, self._easing)
c1 = self._colors[idx % n]
c2 = self._colors[(idx + 1) % n]
return (
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
gradient_store: Optional[Any] = None,
event_bus: Optional["GameEventBus"] = None,
audio_processing_template_store=None,
sync_clock_manager: Optional["SyncClockManager"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
self._gradient_store = gradient_store
self._event_bus = event_bus
self._audio_processing_template_store = audio_processing_template_store
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
# Tracks which clock_id (if any) was acquired for each stream so we
# can release/swap it without re-querying the store at teardown time.
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
def acquire(self, vs_id: str) -> ValueStream:
"""Get or create a shared ValueStream for the given ValueSource.
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
return self._streams[vs_id]
source = self._value_source_store.get_source(vs_id)
stream = self._create_stream(source)
stream = self._create_stream(source, vs_id)
stream.start()
self._streams[vs_id] = stream
self._ref_counts[vs_id] = 1
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
if stream:
stream.stop()
del self._ref_counts[vs_id]
self._release_clock_for(vs_id)
logger.info(f"Released value stream {vs_id} (last ref)")
else:
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
stream = self._streams.get(vs_id)
if stream:
stream.update_source(source)
self._sync_clock_binding(vs_id, source, stream)
logger.debug(f"Updated value stream {vs_id}")
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
return
new_clock_id = getattr(source, "clock_id", None) or None
old_clock_id = self._stream_clock_ids.get(vs_id)
if new_clock_id == old_clock_id:
return
new_runtime = None
if new_clock_id:
try:
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value stream %s: %s",
new_clock_id,
vs_id,
e,
)
new_runtime = None
new_clock_id = None
try:
stream.set_clock(new_runtime)
except Exception as e:
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
if new_clock_id:
self._stream_clock_ids[vs_id] = new_clock_id
else:
self._stream_clock_ids.pop(vs_id, None)
if old_clock_id:
try:
self._sync_clock_manager.release(old_clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
def _release_clock_for(self, vs_id: str) -> None:
"""Release the sync clock acquired for *vs_id* (if any)."""
clock_id = self._stream_clock_ids.pop(vs_id, None)
if clock_id and self._sync_clock_manager:
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", clock_id, e)
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
"""Rebuild audio filter pipelines for any running AudioValueStream
that references the given audio processing template ID.
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
stream.stop()
except Exception as e:
logger.error(f"Error stopping value stream {vs_id}: {e}")
# Release any sync clocks held by streams.
if self._sync_clock_manager:
for vs_id, clock_id in self._stream_clock_ids.items():
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
self._stream_clock_ids.clear()
self._streams.clear()
self._ref_counts.clear()
logger.info("Released all value streams")
def _create_stream(self, source: "ValueSource") -> ValueStream:
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
"""Factory: create the appropriate ValueStream for a ValueSource."""
from ledgrab.storage.value_source import (
AdaptiveValueSource,
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
speed=source.speed,
use_real_time=source.use_real_time,
latitude=source.latitude,
longitude=source.longitude,
min_value=source.min_value,
max_value=source.max_value,
)
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
return StaticColorValueStream(color=source.color)
if isinstance(source, AnimatedColorValueSource):
clock_runtime = None
if source.clock_id and self._sync_clock_manager:
try:
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
if vs_id is not None:
self._stream_clock_ids[vs_id] = source.clock_id
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value source %s: %s",
source.clock_id,
source.id,
e,
)
return AnimatedColorValueStream(
colors=source.colors,
speed=source.speed,
easing=source.easing,
clock=clock_runtime,
)
if isinstance(source, AdaptiveTimeColorValueSource):
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index: Optional[int] = None
self._device_config = None # populated on start(), typed DeviceConfig
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
# Fit-to-device cache (per-instance to avoid cross-target thrash).
# Holds precomputed floor/ceil source indices, fractional weights,
# and reusable scratch buffers so the per-frame interpolation runs
# entirely with in-place numpy ops — no allocations.
self._fit_cache_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None
self._fit_cache_dst: Optional[np.ndarray] = None
self._fit_floor_idx: Optional[np.ndarray] = None
self._fit_ceil_idx: Optional[np.ndarray] = None
self._fit_frac: Optional[np.ndarray] = None
self._fit_left_u8: Optional[np.ndarray] = None
self._fit_right_u8: Optional[np.ndarray] = None
self._fit_blend_f32: Optional[np.ndarray] = None
self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
logger.debug("Device probe failed for %s: %s", device_url, e)
return False
async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None:
"""Background loop that probes the device and updates adaptive state.
Runs independently from the per-frame processing loop so the hot
path doesn't pay for `_probe_task.done()` / scheduling checks every
iteration. Updates ``self._device_reachable``,
``self._metrics.device_streaming_reachable`` and (when adaptive FPS
is enabled) ``self._effective_fps`` directly.
"""
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
while self._is_running:
try:
reachable = await self._probe_device(device_url, client)
except asyncio.CancelledError:
raise
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
if self._adaptive_fps:
target_fps = self._target_fps if self._target_fps > 0 else 30
if not reachable:
old_eff = self._effective_fps
new_eff = max(1, self._effective_fps // 2)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.warning(
"[ADAPTIVE] %s device unreachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
elif self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
new_eff = min(target_fps, self._effective_fps + step)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.info(
"[ADAPTIVE] %s device reachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
if prev_reachable != reachable:
logger.info(
"[PROBE] %s device %s",
self._target_id,
"reachable" if reachable else "UNREACHABLE",
)
# Cooperative sleep that promptly notices stop().
# Sleep in 0.5s chunks so cancellation latency stays < 0.5s.
slept = 0.0
while slept < probe_interval and self._is_running:
chunk = min(0.5, probe_interval - slept)
await asyncio.sleep(chunk)
slept += chunk
def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
@@ -399,8 +469,14 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps
css_timing: dict = {}
css_capture_fps: Optional[int] = None
css_capture_fps_actual: Optional[float] = None
if self._is_running and self._css_stream is not None:
css_timing = self._css_stream.get_last_timing()
css_capture_fps = getattr(self._css_stream, "target_fps", None)
# `actual_fps` is None for synthetic streams (gradient/static/...)
# — only picture/audio/api-input style streams measure it.
css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None)
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
# Picture source timing
@@ -444,6 +520,9 @@ class WledTargetProcessor(TargetProcessor):
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": fps_target,
"fps_capture": css_capture_fps,
"fps_capture_actual": css_capture_fps_actual,
"bytes_sent": metrics.bytes_sent if self._is_running else None,
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
@@ -637,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop -----
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
"""Resample colors to match the target LED count."""
"""Resample colors to match the target LED count.
Linear interpolation using floor/ceil source indices and fractional
weights — all precomputed when ``(n, device_led_count)`` changes.
Per-frame work is two ``np.take`` calls and a few in-place ops on
pre-allocated scratch buffers. No per-frame allocations.
"""
n = len(colors)
if n == device_led_count or device_led_count <= 0:
return colors
key = (n, device_led_count)
if self._fit_cache_key != key:
self._fit_cache_src = np.linspace(0, 1, n)
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
self._fit_cache_key = key
if device_led_count > 1 and n > 1:
t = np.arange(device_led_count, dtype=np.float64) * (
(n - 1) / (device_led_count - 1)
)
else:
t = np.zeros(device_led_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, n - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, n - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast
self._fit_floor_idx = floor_idx
self._fit_ceil_idx = ceil_idx
self._fit_frac = frac
self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32)
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
buf = self._fit_result_buf
for ch in range(min(colors.shape[1], 3)):
np.copyto(
buf[:, ch],
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe",
)
return buf
self._fit_cache_key = key
# Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
left_u8 = self._fit_left_u8
right_u8 = self._fit_right_u8
blend = self._fit_blend_f32
out = self._fit_result_buf
# uint8 → uint8 take with `out=` — no allocation
np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8)
np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8)
# Promote right to float32 in pre-allocated scratch
np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32)
blend -= left_u8 # blend = right - left
blend *= self._fit_frac # blend = frac * (right - left)
blend += left_u8 # blend = left + frac * (right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(out, blend, casting="unsafe") # float32 → uint8
return out
async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms."""
@@ -663,6 +775,8 @@ class WledTargetProcessor(TargetProcessor):
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
# Approximate network throughput counter (LED-payload bytes only).
self._metrics.bytes_sent += int(send_colors.nbytes)
return (time.perf_counter() - t_start) * 1000
@staticmethod
@@ -774,14 +888,16 @@ class WledTargetProcessor(TargetProcessor):
_diag_slow_iters: collections.deque = collections.deque(maxlen=50)
_diag_iter_times: collections.deque = collections.deque(maxlen=300)
# --- Liveness probe + adaptive FPS ---
# The probe runs as an independent task so the hot loop doesn't
# pay for per-iteration probe-state checks.
_device_url = self._device_config.device_url if self._device_config else ""
_probe_enabled = _device_url.startswith("http")
_probe_interval = 10.0 # seconds between probes
_last_probe_time = 0.0 # force first probe soon (after 10s)
_probe_task: Optional[asyncio.Task] = None
_probe_client: Optional[httpx.AsyncClient] = None
if _probe_enabled:
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
_probe_task = asyncio.create_task(
self._run_liveness_probe_loop(_device_url),
name=f"liveness-probe-{self._target_id}",
)
self._effective_fps = self._target_fps
self._device_reachable = None
@@ -805,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
loop_start = now = time.perf_counter()
target_fps = self._target_fps if self._target_fps > 0 else 30
# --- Liveness probe ---
# Collect result as soon as it's done (every iteration)
if _probe_task is not None and _probe_task.done():
try:
reachable = _probe_task.result()
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
_probe_task = None
if self._adaptive_fps:
if not reachable:
# Backoff: halve effective FPS
old_eff = self._effective_fps
self._effective_fps = max(1, self._effective_fps // 2)
if old_eff != self._effective_fps:
logger.warning(
f"[ADAPTIVE] {self._target_id} device unreachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
else:
# Recovery: gradually increase
if self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
self._effective_fps = min(
target_fps, self._effective_fps + step
)
if old_eff != self._effective_fps:
logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
if prev_reachable != reachable:
logger.info(
f"[PROBE] {self._target_id} device "
f"{'reachable' if reachable else 'UNREACHABLE'}"
)
# Fire new probe every _probe_interval seconds
if (
_probe_enabled
and _probe_task is None
and (now - _last_probe_time) >= _probe_interval
):
if _probe_client is not None:
_last_probe_time = now
_probe_task = asyncio.create_task(
self._probe_device(_device_url, _probe_client)
)
# Use effective FPS for frame timing
# Use effective FPS for frame timing. ``self._effective_fps``
# is mutated by the liveness probe task — read once.
effective_fps = self._effective_fps if self._adaptive_fps else target_fps
self._metrics.fps_effective = effective_fps
frame_time = 1.0 / effective_fps
@@ -981,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
await asyncio.sleep(SKIP_REPOLL)
self._metrics.fps_current = _fps_current_from_timestamps()
continue
# Force-send preview when a new client just connected
@@ -1024,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll)
self._metrics.fps_current = _fps_current_from_timestamps()
continue
prev_frame_ref = frame
@@ -1150,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
)
raise
finally:
# Clean up probe client
if _probe_client is not None:
await _probe_client.aclose()
# Stop the liveness probe task. ``_run_liveness_probe_loop``
# owns its own httpx.AsyncClient via ``async with`` so cancelling
# the task closes the client cleanly.
if _probe_task is not None and not _probe_task.done():
_probe_task.cancel()
try:
@@ -588,6 +588,14 @@ class UpdateService:
"body": rel.body,
"prerelease": rel.prerelease,
"published_at": rel.published_at,
"assets": [
{
"name": a.name,
"size": a.size,
"download_url": a.download_url,
}
for a in rel.assets
],
}
if rel
else None
+26
View File
@@ -57,6 +57,7 @@ import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-re
from ledgrab.core.devices.mqtt_client import set_mqtt_service
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
from ledgrab.core.update.update_service import UpdateService
from ledgrab.core.update.gitea_provider import GiteaReleaseProvider
from ledgrab.storage.database import Database
@@ -365,6 +366,24 @@ async def lifespan(app: FastAPI):
)
os_notif_listener.start()
# Start background discovery watcher (mDNS + serial polling).
# Gated by user pref; default is on. The watcher emits
# device_discovered/device_lost events through the same fire_event
# bus that the health monitor uses for device_health_changed.
from ledgrab.api.routes.preferences import load_notification_preferences
discovery_watcher: DiscoveryWatcher | None = None
try:
notif_prefs = load_notification_preferences(db)
if notif_prefs.background_discovery_enabled:
discovery_watcher = DiscoveryWatcher(
device_store=device_store,
fire_event=processor_manager.fire_event,
)
await discovery_watcher.start()
except Exception as e:
logger.error(f"Failed to start discovery watcher: {e}")
yield
# Shutdown
@@ -406,6 +425,13 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Error stopping automation engine: {e}")
# Stop discovery watcher (before health monitor stop so events still flow)
if discovery_watcher is not None:
try:
await discovery_watcher.stop()
except Exception as e:
logger.error(f"Error stopping discovery watcher: {e}")
# Stop OS notification listener
try:
os_notif_listener.stop()
+38 -31
View File
@@ -18,88 +18,95 @@ h1 {
margin-bottom: 0.75rem;
}
/* Responsive preset grid matches the mockup's tight 4-up rhythm
on desktop and gracefully reflows on narrow viewports. */
.ap-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
margin-top: 6px;
}
/* ─── Preset card (shared) ─── */
.ap-card {
--ap-ch: var(--ch-magenta, #ff4ade);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--card-bg);
align-items: stretch;
gap: 4px;
padding: 4px 4px 3px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 8px);
background: var(--lux-bg-1, var(--card-bg));
cursor: pointer;
transition: border-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); }
.ap-card:hover {
border-color: var(--text-muted);
border-color: color-mix(in srgb, var(--ap-ch) 50%, var(--lux-line, var(--border-color)));
transform: translateY(-1px);
}
.ap-card.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color),
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
border: 2px solid var(--ap-ch);
padding: 3px 3px 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent),
0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
}
.ap-card.active::after {
content: '\2713';
position: absolute;
top: 4px;
right: 6px;
font-size: 0.65rem;
top: 3px;
right: 4px;
font-size: 0.55rem;
font-weight: 700;
color: var(--primary-color);
color: var(--ap-ch);
}
.ap-card-label {
font-size: 0.72rem;
font-size: 0.62rem;
font-weight: 600;
color: var(--text-secondary);
color: var(--lux-ink-dim, var(--text-secondary));
text-align: center;
line-height: 1.2;
line-height: 1.15;
letter-spacing: 0.02em;
}
.ap-card.active .ap-card-label {
color: var(--primary-color);
color: var(--ap-ch);
}
/* ─── Style preset preview ─── */
.ap-card-preview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px);
border: 1px solid;
padding: 8px 7px 6px;
padding: 5px 5px 4px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 3px;
overflow: hidden;
}
.ap-card-accent {
width: 24px;
height: 4px;
width: 18px;
height: 3px;
border-radius: 2px;
margin-bottom: 2px;
margin-bottom: 1px;
}
.ap-card-lines {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.ap-card-lines span {
@@ -113,12 +120,12 @@ h1 {
.ap-bg-preview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px);
overflow: hidden;
position: relative;
background: var(--bg-color);
border: 1px solid var(--border-color);
border: 1px solid var(--lux-line, var(--border-color));
}
.ap-bg-preview-inner {
+13 -38
View File
@@ -1,48 +1,23 @@
/* ===== AUTOMATIONS ===== */
.badge-automation-active {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
color: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
}
.badge-automation-inactive {
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-dim, var(--text-color));
}
.badge-automation-disabled {
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.8;
}
.automation-status-disabled {
opacity: 0.6;
}
.automation-logic-label {
font-size: 0.7rem;
/* Chain-arrow separator slips between chips on the AUTO card to
render the rule flow visually (rule + rule scene revert).
Used inside .mod-chips, channel-tinted via the parent's --ch. */
.mod-card .chain-arrow {
display: inline-flex;
align-items: center;
color: var(--ch);
opacity: 0.65;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
padding: 0 4px;
}
/* Automation rule pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
[data-automation-id] .stream-card-prop {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.04em;
user-select: none;
flex-shrink: 0;
}
/* Automation rule editor rows */
+785 -72
View File
@@ -89,8 +89,8 @@ section {
.displays-grid,
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
}
.devices-grid > .loading,
@@ -147,13 +147,15 @@ section {
overflow: hidden;
}
/* Channel stripe on left edge opt-in only:
/* Channel stripe on left edge always on at .6 opacity for cards
* that adopt the modular markup (.mod-card), opt-in for legacy cards.
* [data-has-color="1"] user picked a personal color via the picker
* .card-running "patched and live" indicator
* Idle cards without a personal color stay clean (no stripe), matching
* the pre-redesign behavior where the left border meant "I marked this".
* The dashboard module rows keep their always-on stripe (at 0.6 opacity)
* because the dashboard was approved as-is. */
* .mod-card ambient channel signal (matches dashboard)
* Legacy cards without a personal color stay clean to avoid breaking
* the visual rhythm of feature tabs that haven't migrated yet. Once
* every create*Card() builder uses wrapCard({ mod }), the .mod-card
* scope can be dropped and the stripe becomes ambient on every card. */
.card::before {
content: '';
position: absolute;
@@ -164,13 +166,20 @@ section {
pointer-events: none;
z-index: 1;
display: none;
opacity: 0.6;
transition: opacity 0.2s ease, box-shadow 0.2s ease, width 0.2s ease;
}
.card[data-has-color="1"]::before,
.card.card-running::before {
.card.card-running::before,
.card.mod-card::before {
display: block;
}
.card.mod-card:hover::before {
opacity: 1;
}
/* Corner bracket — silkscreened panel feel in the top-right */
.card::after {
content: '';
@@ -1112,6 +1121,14 @@ body.cs-drag-active .card-drag-handle {
pointer-events: none;
}
/* Mod-card brightness fader — loading + disabled states */
.card.mod-card.brightness-loading .mod-fader,
.template-card.mod-card.brightness-loading .mod-fader,
.mod-fader:has(.mod-fader__slider:disabled) {
opacity: 0.4;
pointer-events: none;
}
/* Static color picker — inline in card-subtitle */
.section-header {
display: flex;
@@ -1236,39 +1253,8 @@ ul.section-tip li {
align-items: center;
}
/* Collapsible target pipeline metrics */
.target-metrics-collapse {
margin-top: 4px;
}
.target-metrics-toggle {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 4px 0;
user-select: none;
background: none;
border: none;
font-family: inherit;
}
.target-metrics-toggle::before {
content: '▸ ';
}
.target-metrics-collapse.open .target-metrics-toggle::before {
content: '▾ ';
}
.target-metrics-animate {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.target-metrics-collapse.open .target-metrics-animate {
grid-template-rows: 1fr;
}
.target-metrics-animate > .target-metrics-expanded {
overflow: hidden;
}
/* Timing breakdown bar */
/* Timing breakdown bar used by graph-editor and the api-input test
modal; the targets card uses its own .target-pipeline wrapper. */
.timing-breakdown {
margin-top: 8px;
padding: 6px 8px;
@@ -1533,81 +1519,258 @@ ul.section-tip li {
display: block;
}
/* Selected card highlight */
/* Selected card highlight accent ring, soft outer glow, and a subtle
lift so the chosen card pops above the dimmed siblings. */
.cs-selecting .card-selected,
.cs-selecting .card-selected.template-card {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent);
box-shadow:
0 0 0 1px var(--primary-color),
0 0 24px color-mix(in srgb, var(--primary-color) 22%, transparent),
0 8px 28px rgba(0, 0, 0, 0.35);
transform: translateY(-1px);
}
/* Make cards visually clickable in selection mode */
/* Non-selected siblings during selection: desaturate, dim and softly blur
so the eye locks onto the active picks. Hover restores full clarity to
keep the card affordance obvious. */
.cs-selecting .card:not(.card-selected),
.cs-selecting .template-card:not(.card-selected) {
opacity: 0.55;
filter: saturate(0.55) blur(0.4px);
transition:
opacity 0.2s ease,
filter 0.25s ease,
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.cs-selecting .card:not(.card-selected):hover,
.cs-selecting .template-card:not(.card-selected):hover {
opacity: 0.92;
filter: none;
}
@media (prefers-reduced-motion: reduce) {
.cs-selecting .card:not(.card-selected),
.cs-selecting .template-card:not(.card-selected) {
filter: saturate(0.55);
}
}
/* Make cards visually clickable in selection mode card root is the
click target, descendants are inert. */
.cs-selecting .card,
.cs-selecting .template-card {
cursor: pointer;
}
/* All card descendants pass clicks through to the card root in
selection mode. This disables buttons, sliders, kebab, color dot,
and any other interactive child without needing per-element rules. */
.cs-selecting .card *,
.cs-selecting .template-card * {
pointer-events: none;
}
/* Suppress hover lift during selection */
.cs-selecting .card:hover,
.cs-selecting .template-card:hover {
transform: none;
}
/* Corner checkmark on selected cards replaces the per-card checkbox
that used to be injected at the top of the card body. Sits in the
top-right corner where the kebab is otherwise visible (kebab is
pointer-events:none in this mode, so the indicator can overlay it). */
.cs-selecting .card-selected::before,
.cs-selecting .template-card.card-selected::before {
/* override the channel stripe pulse by restoring full opacity */
opacity: 1;
}
.cs-selecting .card-selected,
.cs-selecting .template-card.card-selected {
position: relative;
}
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
display: flex;
}
/* The tick itself injected once per card via JS so it picks up the
.card-selected toggle naturally. Hidden until the card is selected. */
.mod-bulk-tick {
display: none;
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
background: var(--primary-color);
color: var(--primary-contrast, #fff);
border-radius: 50%;
box-shadow:
0 0 0 2px var(--card-bg, var(--lux-bg-1)),
0 0 14px color-mix(in srgb, var(--primary-color) 50%, transparent);
z-index: 3;
pointer-events: none;
}
.mod-bulk-tick svg {
display: block;
width: 13px;
height: 13px;
stroke-width: 2.6;
}
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
animation: bulkTickPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes bulkTickPop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
animation: none;
}
}
/* ── Bulk toolbar ──────────────────────────────────────────── */
#bulk-toolbar {
--bulk-ch: var(--primary-color);
position: fixed;
bottom: 20px;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(calc(100% + 30px));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
padding: 8px 16px;
transform: translateX(-50%) translateY(calc(100% + 40px));
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
padding: 8px 10px 8px 12px;
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
z-index: var(--z-bulk-toolbar);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
transition: transform 0.25s ease;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 12px 36px rgba(0, 0, 0, 0.5),
0 4px 16px var(--shadow-color, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
#bulk-toolbar.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
.bulk-select-all-wrap {
/* Top accent stripe — same channel-glow language as modals */
#bulk-toolbar::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--bulk-ch) 20%,
var(--bulk-ch) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--bulk-ch) 55%, transparent);
border-radius: inherit;
pointer-events: none;
}
/* Pick group (Select all / Deselect all) */
.bulk-pick {
display: flex;
align-items: center;
cursor: pointer;
gap: 2px;
padding: 2px;
background: var(--lux-bg-0, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
}
.bulk-select-all-cb {
.bulk-pick-btn {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--lux-ink-mute, var(--text-secondary));
border-radius: var(--lux-r-sm, 3px);
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.bulk-pick-btn .icon {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary-color);
cursor: pointer;
}
.bulk-pick-btn:hover:not(:disabled) {
color: var(--bulk-ch);
background: color-mix(in srgb, var(--bulk-ch) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bulk-ch) 35%, transparent),
0 0 12px color-mix(in srgb, var(--bulk-ch) 30%, transparent);
}
.bulk-pick-btn:disabled,
.bulk-pick-btn[aria-disabled="true"] {
opacity: 0.35;
cursor: not-allowed;
}
.bulk-count {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 80px;
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
letter-spacing: 0.08em;
color: var(--lux-ink, var(--text-color));
min-width: 90px;
padding: 0 4px;
font-variant-numeric: tabular-nums;
}
.bulk-count-total {
color: var(--lux-ink-mute, var(--text-secondary));
font-size: 0.95em;
margin-left: 2px;
}
.bulk-actions {
display: flex;
gap: 4px;
padding-left: 8px;
margin-left: 4px;
border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
}
.bulk-action-btn {
width: 32px;
height: 32px;
padding: 0;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.18s ease, transform 0.15s ease;
}
.bulk-action-btn .icon {
@@ -1615,18 +1778,568 @@ ul.section-tip li {
height: 16px;
}
.bulk-action-btn:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 30%, transparent);
transform: translateY(-1px);
}
.bulk-action-btn.btn-danger:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--danger-color, #ef4444) 40%, transparent);
}
.bulk-action-btn:disabled,
.bulk-action-btn[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.bulk-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
width: 30px;
height: 30px;
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
line-height: 1;
padding: 0;
border-radius: var(--lux-r-sm, 4px);
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 2px;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.bulk-close .icon {
width: 14px;
height: 14px;
}
.bulk-close:hover {
color: var(--text-color);
color: var(--lux-ink, var(--text-color));
border-color: color-mix(in srgb, var(--bulk-ch) 50%, var(--lux-line, var(--border-color)));
background: color-mix(in srgb, var(--bulk-ch) 8%, transparent);
}
@media (prefers-reduced-motion: reduce) {
#bulk-toolbar {
transition: opacity 0.15s ease;
}
.bulk-action-btn:hover:not(:disabled) {
transform: none;
}
}
/*
Mod-cards entity cards that adopt the dashboard's `.mod-*` markup
The `.mod-*` child selectors (mod-head, mod-leds, mod-metrics,
mod-foot, mod-patch, mod-btn, etc.) live in dashboard.css and apply
regardless of host class. The rules below adapt the .card /
.template-card hosts to layout the modular children correctly (gap,
padding, suppress legacy decorations) and add the kebab menu styles
that are unique to entity cards.
*/
.card.mod-card,
.template-card.mod-card {
/* Vertical flex stack matches .dashboard-target:has(.mod-head) */
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 18px 14px 22px;
align-items: stretch;
}
/* Foot pinned to the card bottom entity cards have variable body
content (chips/metrics/fader may all be absent), so without this the
foot floats in the middle when the grid forces a tall card. */
.card.mod-card .mod-foot,
.template-card.mod-card .mod-foot {
margin-top: auto;
}
/* Suppress legacy decorations when modular children are present */
.card.mod-card .card-header,
.card.mod-card .card-actions,
.card.mod-card .card-top-actions,
.template-card.mod-card .template-card-header,
.template-card.mod-card .template-card-actions,
.template-card.mod-card .card-top-actions { display: none; }
/* Idle silkscreen corner bracket drop on mod-cards with a kebab.
The kebab visually replaces the bracket as the top-right anchor. */
.card.mod-card:has(.mod-menu-wrap):not(.is-running)::after,
.template-card.mod-card:has(.mod-menu-wrap):not(.is-running)::after {
display: none;
}
/* Running state on mod-cards same intensified stripe + bottom signal-
flow used by .dashboard-target.is-running. Two class names keep
markup interchangeable: callers can use `is-running` (dashboard
convention) or `card-running` (legacy entity-card convention). */
.card.mod-card.is-running,
.card.mod-card.card-running,
.template-card.mod-card.is-running,
.template-card.mod-card.card-running {
border-color: color-mix(in srgb, var(--ch) 32%, var(--lux-line, var(--border-color)));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch) 18%, transparent),
0 6px 20px rgba(0, 0, 0, 0.25);
}
.card.mod-card.is-running::before,
.card.mod-card.card-running::before,
.template-card.mod-card.is-running::before,
.template-card.mod-card.card-running::before {
opacity: 1;
width: 4px;
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent),
0 0 4px color-mix(in srgb, var(--ch) 90%, transparent);
}
/* Bottom signal-flow strip (running indicator) */
.card.mod-card.is-running::after,
.card.mod-card.card-running::after,
.template-card.mod-card.is-running::after,
.template-card.mod-card.card-running::after {
content: '';
position: absolute;
top: auto; right: auto;
left: 4px; bottom: 0;
width: calc(100% - 4px); height: 2px;
border: none;
opacity: 0.7;
background: linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--ch) 85%, transparent) 50%,
transparent 100%);
background-size: 30% 100%;
background-repeat: no-repeat;
animation: signalFlow 2.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.card.mod-card.is-running::after,
.card.mod-card.card-running::after,
.template-card.mod-card.is-running::after,
.template-card.mod-card.card-running::after {
animation: none;
background-position: 50% 0;
background-size: 60% 100%;
}
}
/* ── Color picker dot inside .mod-badge ─────────────────────────── */
.mod-badge { padding-left: 4px; }
.mod-badge__color-wrap {
display: inline-flex;
align-items: center;
margin-right: 6px;
line-height: 0;
position: relative;
}
.mod-badge__color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--ch);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch) 40%, transparent);
}
.mod-badge__color:hover,
.mod-badge__color:focus-visible {
transform: scale(1.25);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ch) 25%, transparent);
outline: none;
}
/* User picked a personal hue fill with --user-color but keep the
channel-tinted ring */
.mod-badge__color[data-custom] {
background: var(--user-color);
}
/* ── Overflow menu (kebab) ──────────────────────────────────────── */
.mod-menu-wrap {
position: relative;
flex-shrink: 0;
align-self: flex-start;
/* Match the .mod-leds bezel height (~22px) so the kebab and the LED
cluster sit on the same visual baseline at the top of the head row.
Without this, the 26px kebab dropped 4px below the 22px bezel and
read as misaligned. */
display: inline-flex;
align-items: center;
height: 22px;
}
.mod-menu-btn {
appearance: none;
background: transparent;
border: none;
width: 22px; height: 22px;
border-radius: 3px;
color: var(--lux-ink-mute, var(--text-secondary));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, background 0.15s, opacity 0.15s;
opacity: 0.45;
padding: 0;
}
.card.mod-card:hover .mod-menu-btn,
.template-card.mod-card:hover .mod-menu-btn,
.dashboard-target:hover .mod-menu-btn,
.mod-menu-wrap.is-open .mod-menu-btn {
opacity: 1;
}
.mod-menu-btn:hover,
.mod-menu-wrap.is-open .mod-menu-btn {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-3, var(--border-color));
}
.mod-menu-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 50%, transparent);
}
.mod-menu-btn .icon {
width: 14px;
height: 14px;
/* The kebab path uses fill (filled circles) — disable stroke */
stroke: none;
fill: currentColor;
}
/* The dropdown `position: fixed` with JS-computed coordinates.
* mod-menu.ts portals the element to <body> on open so that a
* transformed ancestor (e.g. card hover translate) doesn't trap the
* fixed positioning, and the card's `overflow: hidden` doesn't clip
* the dropdown. Display toggles on `.mod-menu.is-open` (set whether
* the menu is in the wrap or portaled to body). */
.mod-menu {
position: fixed;
z-index: var(--z-dropdown, 200);
min-width: 172px;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.02);
padding: 4px;
display: none;
flex-direction: column;
transform-origin: top right;
animation: modMenuIn 0.12s cubic-bezier(0.16, 1, 0.3, 1);
}
.mod-menu.is-open { display: flex; }
@keyframes modMenuIn {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: none; }
}
.mod-menu__item {
appearance: none;
background: transparent;
border: none;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 2px;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-secondary));
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
width: 100%;
}
.mod-menu__item:hover,
.mod-menu__item:focus-visible {
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink, var(--text-color));
outline: none;
}
.mod-menu__item .icon {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--lux-ink-mute, var(--text-secondary));
}
.mod-menu__item:hover .icon,
.mod-menu__item:focus-visible .icon {
color: var(--lux-ink, var(--text-color));
}
.mod-menu__sep {
height: 1px;
margin: 4px 6px;
background: var(--lux-line, var(--border-color));
}
.mod-menu__item--danger {
color: var(--ch-coral, var(--danger-color));
}
.mod-menu__item--danger .icon {
color: var(--ch-coral, var(--danger-color));
opacity: 0.85;
}
.mod-menu__item--danger:hover,
.mod-menu__item--danger:focus-visible {
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent);
color: var(--ch-coral, var(--danger-color));
}
.mod-menu__item--danger:hover .icon,
.mod-menu__item--danger:focus-visible .icon {
color: var(--ch-coral, var(--danger-color));
opacity: 1;
}
/* Property chips (replaces parallel .card-meta / .stream-card-prop
systems on mod-cards). Reused by the legacy .stream-card-prop
class so cards can mix-and-match during migration. */
.mod-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mod-card .chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--lux-ink-dim, var(--text-secondary));
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 3px 9px;
border-radius: var(--lux-r-sm, 3px);
cursor: default;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.mod-card .chip .icon {
width: 11px;
height: 11px;
flex-shrink: 0;
color: var(--ch);
}
.mod-card .chip--link { cursor: pointer; }
.mod-card .chip--link:hover {
color: var(--lux-ink, var(--text-color));
border-color: color-mix(in srgb, var(--ch) 40%, transparent);
background: color-mix(in srgb, var(--ch) 12%, transparent);
}
.mod-card .chip--tag {
color: var(--ch);
border-color: color-mix(in srgb, var(--ch) 22%, transparent);
background: color-mix(in srgb, var(--ch) 10%, transparent);
}
.mod-card .chip--err {
color: var(--ch-coral, var(--danger-color));
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent);
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, transparent);
}
/* Inline chip variant used by the pipeline strip on running target
cards. A bigger numeric (display-font-ish) sits beside a small mono
caps unit, so a chip can carry "127K · frames" without looking like
a label-shaped pill. The data values inside still update via
_patchTargetMetrics. */
.mod-card .chip--inline {
gap: 4px;
padding: 3px 8px;
}
.mod-card .chip--inline > span {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
font-weight: 700;
color: var(--lux-ink, var(--text-color));
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.mod-card .chip--inline > small {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
}
/* Pipeline strip replaces the old target-metrics-collapse. A
thin always-visible segmented timing bar plus a tight chip row
below it (timing total / frames / keepalive). The bar is the
diagnostic; the chips are the counters. */
.mod-card .target-pipeline {
display: flex;
flex-direction: column;
gap: 8px;
}
.mod-card .target-pipeline .timing-bar {
height: 4px;
border-radius: 2px;
background: var(--lux-bg-0, var(--bg-color));
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
margin: 0;
overflow: hidden;
gap: 0;
}
.mod-card .target-pipeline .timing-seg {
transition: flex 0.3s ease;
opacity: 0.85;
}
.mod-card .target-pipeline .timing-seg:hover {
opacity: 1;
}
.mod-card .target-pipeline-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── Brightness fader (mod-card variant) ────────────────────────── */
.mod-fader {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
background: var(--lux-bg-0, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
}
.mod-fader__k {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
min-width: 42px;
}
.mod-fader__track {
flex: 1;
position: relative;
height: 5px;
border-radius: 99px;
background: color-mix(in srgb, var(--ch) 12%, var(--lux-bg-3, var(--border-color)));
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
[data-theme="light"] .mod-fader__track {
box-shadow: inset 0 1px 2px rgba(15, 20, 25, 0.08);
}
.mod-fader__fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg,
color-mix(in srgb, var(--ch) 50%, transparent),
var(--ch));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent);
}
/* The slider input lays flat over the track, transparent, so the user
drags the visual track without seeing the native control. */
.mod-fader { position: relative; }
.mod-fader__slider {
position: absolute;
left: 52px;
right: 50px; /* between label and value cells */
top: 50%;
transform: translateY(-50%);
height: 18px;
width: auto;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.mod-fader__v {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
font-weight: 700;
color: var(--lux-ink, var(--text-color));
min-width: 34px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Preview surface (gradient strip / asset thumb / LED preview) ── */
.mod-preview {
border-radius: var(--lux-r-sm, 4px);
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
overflow: hidden;
position: relative;
}
.mod-preview canvas {
display: block;
width: 100%;
height: 100%;
}
/* Corner tag overlay on a preview surface (e.g. "BUILT-IN" on built-in
gradient strips). Sits in the top-right with a backdrop-blurred dark
pill so it stays legible on any gradient light, dark, or mid-tone. */
.mod-preview__tag {
position: absolute;
top: 5px;
right: 5px;
padding: 2px 7px;
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
line-height: 1.4;
color: rgba(255, 255, 255, 0.95);
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--lux-r-sm, 3px);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: none;
user-select: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
/* ── Description text ──────────────────────────────────────────── */
.mod-desc {
font-size: 0.82rem;
color: var(--lux-ink-dim, var(--text-secondary));
line-height: 1.45;
}
/* Mod-card specific tweaks for chips inside cards
The flex order in .mod-foot is: .mod-patch (margin-right:auto) on
the left, primary action(s) on the right. Multiple .chip rows above
the foot inherit normal flex-wrap behavior from .mod-chips. */
/* Mobile — reduce padding on mod-cards */
@media (max-width: 768px) {
.card.mod-card,
.template-card.mod-card {
padding: 14px 14px 12px 18px;
gap: 12px;
}
.mod-menu {
min-width: 200px;
}
}
+192 -30
View File
@@ -94,6 +94,19 @@
background: var(--lux-bg-3, var(--border-color));
}
/* Transparent hairline variant used for low-emphasis actions like
"Revert" inside the per-section save bar. */
.btn-ghost {
background: transparent;
color: var(--lux-ink-dim, var(--text-color));
border-color: var(--lux-line, var(--border-color));
}
.btn-ghost:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--lux-ink, var(--text-color));
border-color: var(--lux-line-bold, var(--border-color));
}
.btn-icon {
min-width: auto;
padding: 7px 10px;
@@ -434,21 +447,52 @@ input:-webkit-autofill:focus {
}
.toast {
--toast-ch: var(--primary-color);
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 16px 24px;
border-radius: var(--radius-md);
color: white;
padding: 14px 22px;
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
color: var(--lux-ink, #fff);
font-weight: 600;
font-size: 15px;
font-size: 14.5px;
letter-spacing: 0.01em;
opacity: 0;
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
z-index: var(--z-toast);
box-shadow: 0 4px 20px var(--shadow-color);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--toast-ch) 35%, var(--lux-line-bold, var(--border-color)));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 14px 40px rgba(0, 0, 0, 0.5),
0 0 26px color-mix(in srgb, var(--toast-ch) 22%, transparent);
min-width: 300px;
max-width: min(560px, calc(100vw - 32px));
text-align: center;
overflow: hidden;
}
/* Top accent stripe matches modals/bulk-toolbar so the channel language
is consistent across every floating surface. */
.toast::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--toast-ch) 20%,
var(--toast-ch) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--toast-ch) 55%, transparent);
pointer-events: none;
}
.toast.show {
@@ -458,23 +502,15 @@ input:-webkit-autofill:focus {
}
@keyframes toastBounceIn {
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
70% { transform: translateX(-50%) translateY(2px); }
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
70% { transform: translateX(-50%) translateY(2px); }
100% { transform: translateX(-50%) translateY(0); }
}
.toast.success {
background: var(--primary-color);
}
.toast.error {
background: var(--danger-color);
}
.toast.info {
background: var(--info-color);
}
.toast.success { --toast-ch: var(--primary-color); }
.toast.error { --toast-ch: var(--danger-color); }
.toast.info { --toast-ch: var(--info-color); }
/* Toast with undo action */
.toast-with-action {
@@ -488,31 +524,38 @@ input:-webkit-autofill:focus {
}
.toast-undo-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 4px 12px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--toast-ch) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--toast-ch) 50%, transparent);
color: var(--lux-ink, #fff);
padding: 5px 14px;
border-radius: var(--lux-r-sm, var(--radius-sm));
font-weight: var(--weight-semibold, 600);
font-size: 0.85rem;
letter-spacing: 0.04em;
cursor: pointer;
transition: background var(--duration-fast, 0.15s);
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.toast-undo-btn:hover {
background: rgba(255, 255, 255, 0.4);
background: color-mix(in srgb, var(--toast-ch) 32%, transparent);
border-color: var(--toast-ch);
box-shadow: 0 0 14px color-mix(in srgb, var(--toast-ch) 35%, transparent);
}
.toast-timer {
width: 100%;
height: 3px;
height: 2px;
position: absolute;
bottom: 0;
left: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: rgba(255, 255, 255, 0.3);
border-radius: 0 0 var(--lux-r-lg, var(--radius-md)) var(--lux-r-lg, var(--radius-md));
background: linear-gradient(90deg,
color-mix(in srgb, var(--toast-ch) 70%, transparent) 0%,
var(--toast-ch) 50%,
color-mix(in srgb, var(--toast-ch) 70%, transparent) 100%);
box-shadow: 0 0 8px color-mix(in srgb, var(--toast-ch) 60%, transparent);
transform-origin: left;
animation: toastTimer var(--toast-duration, 5s) linear forwards;
}
@@ -749,6 +792,15 @@ textarea:focus-visible {
margin-left: auto;
}
/* Hide empty icon/label slots so flex gaps don't reserve unused space.
Used by minimal items like the locale picker (2-letter code only). */
.icon-select-trigger-icon:empty,
.icon-select-trigger-label:empty,
.icon-select-cell-icon:empty,
.icon-select-cell-label:empty {
display: none;
}
.icon-select-popup {
position: fixed;
z-index: var(--z-lightbox);
@@ -1107,6 +1159,116 @@ textarea:focus-visible {
font-size: 0.9rem;
}
/* Timezone picker (Settings modal)
Refined "instrument readout" trigger that matches the lux
sectional design language used in the rest of the modal:
- hairline border, deeper bg, monospaced offset chip
- region icon stenciled in --ch-cyan to echo section dots
- subtle hover glow on the channel hue, not the primary green */
.tz-picker-wrap {
position: relative;
display: block;
isolation: isolate;
}
.tz-picker-wrap .entity-select-trigger {
--tz-ch: var(--ch-cyan, var(--primary-color));
background: var(--lux-bg-1, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
padding: 10px 12px;
gap: 12px;
font-feature-settings: "ss01", "tnum";
font-variant-numeric: tabular-nums;
color: var(--lux-ink, var(--text-color));
transition:
border-color 180ms var(--ease-out, ease-out),
background 180ms var(--ease-out, ease-out),
box-shadow 220ms var(--ease-out, ease-out);
}
.tz-picker-wrap .entity-select-trigger::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 2px;
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--tz-ch) 80%, transparent),
color-mix(in srgb, var(--tz-ch) 0%, transparent) 70%
);
border-radius: var(--lux-r-md, 6px) 0 0 var(--lux-r-md, 6px);
opacity: 0.75;
pointer-events: none;
transition: opacity 220ms var(--ease-out, ease-out);
}
.tz-picker-wrap .entity-select-trigger:hover {
border-color: color-mix(in srgb, var(--tz-ch) 55%, var(--lux-line-bold, var(--border-color)));
background: var(--lux-bg-2, var(--bg-secondary));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tz-ch) 18%, transparent),
0 8px 24px -10px color-mix(in srgb, var(--tz-ch) 30%, transparent);
}
.tz-picker-wrap .entity-select-trigger:hover::before {
opacity: 1;
}
.tz-picker-wrap .entity-select-trigger:focus-visible {
outline: none;
border-color: var(--tz-ch);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--tz-ch) 25%, transparent);
}
.tz-picker-wrap .es-trigger-icon {
width: 28px;
height: 28px;
border-radius: var(--lux-r-sm, 3px);
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--tz-ch) 12%, transparent);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--tz-ch) 25%, transparent);
color: var(--tz-ch);
}
.tz-picker-wrap .es-trigger-icon .icon {
width: 14px;
height: 14px;
}
.tz-picker-wrap .es-trigger-label {
font-weight: 600;
letter-spacing: 0.01em;
color: var(--lux-ink, var(--text-color));
}
/* Render the offset segment after the city in monospaced "panel-meter" type */
.tz-picker-wrap .es-trigger-label {
/* Allow ellipsis to keep the trigger compact in narrow layouts. */
min-width: 0;
}
.tz-picker-wrap .es-trigger-arrow {
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.7;
transform: translateY(-1px);
transition: transform 220ms var(--ease-out, ease-out), color 180ms ease;
}
.tz-picker-wrap:hover .es-trigger-arrow {
color: var(--tz-ch);
opacity: 1;
transform: translateY(0);
}
/* When the value is the system default (empty string), dim the label slightly
so users can see it is in the "no override" state at a glance. */
.tz-picker-wrap .entity-select-trigger:has(.es-trigger-none) {
border-style: dashed;
}
/* ── Scroll-to-top button ── */
.scroll-to-top {
+152 -1
View File
@@ -286,6 +286,42 @@
text-overflow: ellipsis;
}
/* Device URL hyperlink inside the meta line.
Picks up the card's channel accent (--ch) so it ties into the card's
color identity instead of using the generic browser-blue link style. */
.mod-meta__link {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: var(--ch, var(--primary-text-color));
text-decoration: none;
border-bottom: 1px dotted color-mix(in srgb, var(--ch, var(--primary-color)) 55%, transparent);
padding-bottom: 1px;
transition: color 0.15s ease, border-color 0.15s ease, text-shadow 0.15s ease;
}
.mod-meta__link:hover,
.mod-meta__link:focus-visible {
color: var(--ch, var(--primary-color));
border-bottom-style: solid;
border-bottom-color: currentColor;
text-shadow: 0 0 8px color-mix(in srgb, var(--ch, var(--primary-color)) 35%, transparent);
outline: none;
}
.mod-meta__link .icon {
width: 0.95em;
height: 0.95em;
transform: translateY(0.12em);
opacity: 0.7;
transition: opacity 0.15s ease;
}
.mod-meta__link:hover .icon,
.mod-meta__link:focus-visible .icon {
opacity: 1;
}
.mod-leds {
display: flex;
align-items: center;
@@ -404,6 +440,34 @@
margin-left: 3px;
}
/* Text-stack variant used for short identifier values that don't render
well in the heavy display font (e.g. SK6812 + RGBW). The primary line
sits in the same slot as `.v`, and an optional `.v-sub` element below
carries a secondary line in the small mono caps style. */
.mod-metric--text-stack .v {
font-family: var(--font-mono, monospace);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.15;
white-space: normal;
overflow: visible;
text-overflow: clip;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.mod-metric--text-stack .v-sub {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.85;
}
.mod-metric .v .dashboard-fps-target {
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
@@ -1139,6 +1203,12 @@
padding: 0 18px 14px;
cursor: crosshair;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent));
/* Clip the scroll-out animation: when a new sample arrives the SVG
is snap-translated +1 step right then eased back to 0, so the
previous sample's frame remains visually anchored while old
samples slide off the left. Without overflow:hidden the temporary
overshoot would peek into the padding. */
overflow: hidden;
}
.perf-chart-spark .perf-chart-svg {
@@ -1149,6 +1219,18 @@
width: 100%;
height: 100%;
display: block;
/* Promote to its own compositor layer so the scroll animation runs
on the GPU the path strings underneath don't repaint, only the
layer transform updates per frame. */
will-change: transform;
}
/* Honor the global animations preference. "reduced" keeps the scroll
but cuts duration so motion is brief; "off" pins the SVG so each
tick is a hard cut. */
[data-layout-anim="off"] .perf-chart-spark .perf-chart-svg {
transition: none !important;
transform: none !important;
}
.perf-chart-unavailable {
@@ -1262,6 +1344,69 @@
the list owns the rest of the cell height. */
.perf-patches-cell .perf-chart-spark { display: none; }
/* Errors cell value is muted at 0 (healthy state, no alarm) and
shifts to the cell's coral accent the moment the rate or cumulative
count goes non-zero. The accent stripe + value tint give a passive
"is anything wrong?" indicator without flashing or animation. */
.perf-errors-cell .perf-chart-value {
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.65;
}
.perf-errors-cell.has-errors .perf-chart-value {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 1;
}
.perf-errors-cell.has-errors .perf-chart-subtitle {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 0.85;
}
/* Empty-state hint shown when no patches are running. A pulsing accent
dot keeps the card visually alive even when idle, and the small caps
text reads as a status line ("Ready to launch") rather than a stale
empty list. */
.perf-patches-empty {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.85;
}
.perf-patches-empty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
box-shadow: 0 0 6px currentColor;
color: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
flex-shrink: 0;
animation: perfPatchesIdlePulse 2.4s ease-in-out infinite;
}
.perf-patches-empty-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes perfPatchesIdlePulse {
0%, 100% { opacity: 0.45; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
}
/* Honor the global "reduced/off" animations preference set on the
dashboard root the pulse vanishes when the user wants stillness. */
[data-layout-anim="off"] .perf-patches-empty-dot,
[data-layout-anim="reduced"] .perf-patches-empty-dot {
animation: none;
opacity: 0.7;
}
/* ── Devices cell — online/total count + dot strip per device ── */
.perf-devices-cell {
--perf-accent: var(--ch-signal, var(--primary-color));
@@ -1323,7 +1468,7 @@
color: var(--perf-accent);
}
.perf-chart-card[data-metric="fps"] .perf-fps-unit {
.perf-chart-card .perf-fps-unit {
font-family: var(--font-mono, monospace);
font-size: 0.3em;
font-weight: 500;
@@ -1333,6 +1478,12 @@
margin-left: 6px;
align-self: center;
}
/* Errors cell when the rate is non-zero, the unit takes the same coral
tint as the value so they read as a single composite reading. */
.perf-errors-cell.has-errors .perf-fps-unit {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 0.85;
}
/* Target-FPS ceiling suffix "/ 120" next to the big live number, sized
down + muted so the live value remains the primary reading. Matches
@@ -430,7 +430,7 @@ html:has(#tab-graph.active) {
}
.graph-node-body {
fill: var(--card-bg);
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--lux-line, var(--border-color));
stroke-width: 1;
rx: 6;
@@ -723,7 +723,7 @@ html:has(#tab-graph.active) {
}
.graph-node-overlay-bg {
fill: var(--card-bg);
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--border-color);
stroke-width: 1;
rx: 6;
+9 -43
View File
@@ -151,8 +151,6 @@ h1 {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--lux-ink-dim, var(--text-secondary));
min-width: 0;
overflow: hidden;
}
.transport-status {
@@ -382,26 +380,27 @@ h2 {
font-family: var(--font-mono, 'Orbitron', sans-serif);
font-size: 0.55rem;
font-weight: 600;
color: var(--lux-ink-mute, var(--text-secondary));
color: var(--ch-signal, var(--primary-color));
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
padding: 2px 6px;
border-radius: 2px;
letter-spacing: 0.12em;
text-transform: uppercase;
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
transition: background 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s;
}
#server-version.has-update {
background: var(--warning-color);
background: var(--ch-signal, var(--primary-color));
border-color: var(--ch-signal, var(--primary-color));
color: #fff;
cursor: pointer;
animation: updatePulse 2s ease-in-out infinite;
}
@keyframes updatePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
}
/* ── Update banner ── */
@@ -770,12 +769,12 @@ h2 {
height: 30px;
padding: 0;
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border: none;
border-radius: var(--lux-r-sm, 3px);
cursor: pointer;
font-size: 0.9rem;
color: var(--lux-ink-dim, var(--text-secondary));
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
display: inline-grid;
place-items: center;
line-height: 1;
@@ -785,7 +784,6 @@ h2 {
.header-btn:hover {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-2, var(--bg-secondary));
border-color: var(--lux-line-bold, var(--border-color));
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
}
@@ -888,38 +886,6 @@ h2 {
flex-shrink: 0;
}
/* Footer */
.app-footer {
margin-top: 12px;
padding: 6px 0;
text-align: center;
}
.footer-content {
color: var(--text-secondary);
font-size: 0.75rem;
}
.footer-content p {
margin: 0;
}
.footer-content strong {
color: var(--text-color);
}
.footer-content a {
color: var(--primary-text-color);
text-decoration: none;
transition: opacity 0.2s;
}
.footer-content a:hover {
opacity: 0.8;
text-decoration: underline;
}
/* Command Palette */
#command-palette {
position: fixed;
-5
View File
@@ -481,11 +481,6 @@
margin-bottom: 10px;
}
/* Footer */
.app-footer {
margin-bottom: 50px;
}
/* Command palette */
#command-palette {
padding-top: 5vh;
File diff suppressed because it is too large Load Diff
+57 -23
View File
@@ -10,7 +10,7 @@
.template-card {
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
background: var(--card-bg);
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
@@ -834,55 +834,89 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.cs-filter-wrap {
position: relative;
width: 180px;
max-width: 40%;
width: 220px;
max-width: 45%;
flex-shrink: 0;
}
/* Search input with embedded magnifier icon (data URI keeps the HTML
untouched), neon focus glow, monospace placeholder for the technical
look used elsewhere in the dashboard. */
.cs-filter-wrap .cs-filter {
width: 100%;
padding: 4px 26px 4px 10px;
font-size: 0.78rem;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
color: var(--text-color);
padding: 7px 32px 7px 32px;
font-family: var(--font-mono, monospace);
font-size: 0.76rem;
letter-spacing: 0.04em;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
background-color: var(--lux-bg-0, var(--bg-secondary));
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a8a8a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>");
background-repeat: no-repeat;
background-position: 10px center;
background-size: 14px 14px;
color: var(--lux-ink, var(--text-color));
outline: none;
box-shadow: none;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
box-sizing: border-box;
transition: border-color 0.2s, background 0.2s, width 0.2s;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.2s ease;
}
.cs-filter-wrap .cs-filter:hover {
border-color: var(--lux-line-bold, var(--border-color));
}
.cs-filter-wrap .cs-filter:focus {
border-color: var(--primary-color);
background: var(--bg-color);
background-color: var(--lux-bg-1, var(--bg-color));
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.3),
0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent),
0 0 18px color-mix(in srgb, var(--primary-color) 22%, transparent);
}
.cs-filter::placeholder {
color: var(--text-secondary);
font-size: 0.75rem;
color: var(--lux-ink-faint, var(--text-secondary));
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
.cs-filter-reset {
position: absolute;
right: 2px;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
width: 22px;
height: 22px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1rem;
color: var(--lux-ink-mute, var(--text-secondary));
font-size: 0.95rem;
cursor: pointer;
padding: 0 5px;
padding: 0;
line-height: 1;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
border-radius: var(--lux-r-sm, 3px);
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
/* Hide the clear button when the input is empty CSS-only so the visual
state stays correct regardless of any JS-set inline display value. */
.cs-filter-wrap .cs-filter:placeholder-shown + .cs-filter-reset {
display: none;
}
.cs-filter-reset:hover {
color: var(--text-color);
background: var(--border-color);
color: var(--lux-ink, var(--text-color));
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* Empty state for CardSection */
+343
View File
@@ -195,3 +195,346 @@
}
/* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */
/*
v2 "Signal Bench" opt-in via .tutorial-v2 on the overlay root.
Keeps the legacy ring+tooltip CSS untouched so unmigrated tours keep
working. Aligns with the lux/instrument language used elsewhere in
the UI (hairlines, JetBrains Mono labels, --ch-signal accent).
*/
/* Backdrop: dimmer page + a hint of grain so the cutout reads as an
instrument viewfinder instead of a flat hole-punch. */
.tutorial-overlay.tutorial-v2 .tutorial-backdrop {
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.018) 0px,
rgba(255, 255, 255, 0.018) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(
1400px 900px at 50% 35%,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.92) 100%
);
transition: clip-path 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
[data-theme="light"] .tutorial-overlay.tutorial-v2 .tutorial-backdrop {
background:
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.02) 0px,
rgba(0, 0, 0, 0.02) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(
1400px 900px at 50% 35%,
rgba(20, 24, 30, 0.55) 0%,
rgba(20, 24, 30, 0.72) 100%
);
}
/* Ring becomes a hairline + signal-glow frame; the prominent visual is
the 4 corner brackets layered on top. */
.tutorial-overlay.tutorial-v2 .tutorial-ring {
border: 1px dashed color-mix(in srgb, var(--ch-signal) 38%, transparent);
border-radius: 2px;
animation: none;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--ch-signal) 14%, transparent),
0 0 22px color-mix(in srgb, var(--ch-signal) 28%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--ch-signal) 6%, transparent);
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
height 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid var(--ch-signal);
pointer-events: none;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--ch-signal) 55%, transparent));
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tl {
top: -2px; left: -2px;
border-right: none; border-bottom: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tr {
top: -2px; right: -2px;
border-left: none; border-bottom: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.bl {
bottom: -2px; left: -2px;
border-right: none; border-top: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.br {
bottom: -2px; right: -2px;
border-left: none; border-top: none;
}
/* Animate corner draw-in on each step change (ring already eases its
bounds; this adds a snappy "lock" on top). */
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner {
animation: tutorial-corner-lock 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes tutorial-corner-lock {
0% { opacity: 0; transform: scale(2.4); }
60% { opacity: 1; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}
/* Patch cable connects reticle to tooltip. Animated dash-offset reads
as transmission flowing toward the tooltip. Absolute by default for
modal-mode overlays; viewport-fixed for tour overlays via the
.tutorial-overlay-fixed class. */
.tutorial-overlay.tutorial-v2 .tutorial-cable {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 101;
overflow: visible;
}
.tutorial-overlay-fixed.tutorial-v2 .tutorial-cable {
position: fixed;
}
.tutorial-overlay.tutorial-v2 .tutorial-cable line {
stroke: var(--ch-signal);
stroke-width: 1.25;
stroke-dasharray: 4 5;
stroke-linecap: round;
fill: none;
opacity: 0.7;
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ch-signal) 50%, transparent));
animation: tutorial-cable-flow 1.4s linear infinite;
transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tutorial-cable-flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -18; }
}
/* Tooltip instrument readout: square corners, hairline border,
inner ring, lux shadow. */
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
width: 296px;
background: var(--card-bg);
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
box-shadow:
0 0 0 1px var(--lux-bg-2),
var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.5)),
0 0 32px color-mix(in srgb, var(--ch-signal) 12%, transparent);
animation: tutorial-tooltip-v2-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
position: relative;
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--ch-signal) 20%,
var(--ch-signal) 80%,
transparent 100%
);
opacity: 0.85;
}
@keyframes tutorial-tooltip-v2-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--lux-line);
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-eyebrow {
flex: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
color: var(--ch-signal);
text-transform: uppercase;
}
.tutorial-overlay.tutorial-v2 .tutorial-step-counter {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--lux-ink-dim);
font-variant-numeric: tabular-nums;
}
.tutorial-tooltip-breadcrumb {
display: none;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-muted));
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tutorial-tooltip-breadcrumb.is-visible {
display: inline-block;
}
.tutorial-tooltip-breadcrumb.is-visible + .tutorial-step-counter::before {
content: '· ';
color: var(--lux-ink-mute, var(--text-muted));
margin-right: 4px;
}
.tutorial-overlay.tutorial-v2 .tutorial-close-btn {
color: var(--lux-ink-mute);
border-radius: 2px;
}
.tutorial-overlay.tutorial-v2 .tutorial-close-btn:hover {
color: var(--lux-ink);
background: var(--lux-bg-2);
}
/* Segmented progress pips — one slot per step. */
.tutorial-overlay.tutorial-v2 .tutorial-pips {
display: flex;
gap: 3px;
padding: 10px 12px 4px;
}
.tutorial-overlay.tutorial-v2 .tutorial-pip {
flex: 1;
height: 3px;
background: var(--lux-line);
border-radius: 1px;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.tutorial-overlay.tutorial-v2 .tutorial-pip.done {
background: color-mix(in srgb, var(--ch-signal) 60%, var(--lux-line));
}
.tutorial-overlay.tutorial-v2 .tutorial-pip.active {
background: var(--ch-signal);
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal) 60%, transparent);
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-text {
margin: 0;
padding: 8px 14px 14px;
line-height: 1.55;
color: var(--lux-ink);
font-size: 13.5px;
font-weight: 400;
letter-spacing: 0.005em;
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-nav {
display: flex;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--lux-line);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn,
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn {
background: transparent;
color: var(--lux-ink-dim);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:hover:not(:disabled) {
color: var(--lux-ink);
border-color: var(--lux-ink-mute);
background: var(--lux-bg-2);
}
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
background: var(--ch-signal);
color: var(--primary-contrast, #000);
border-color: var(--ch-signal);
}
.tutorial-overlay.tutorial-v2 .tutorial-next-btn:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--ch-signal) 50%, transparent);
filter: brightness(1.06);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Keyboard hint chip — surfaces existing arrow/Esc bindings. */
.tutorial-overlay.tutorial-v2 .tutorial-keyhint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 10px;
border-top: 1px solid var(--lux-line);
background: var(--lux-bg-1);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.1em;
color: var(--lux-ink-mute);
text-transform: uppercase;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--lux-bg-3);
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
font: inherit;
font-size: 10px;
color: var(--lux-ink-dim);
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span {
margin-right: 6px;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span:last-child {
margin-right: 0;
}
/* Mobile — collapse cable, dock tooltip to bottom of viewport. */
@media (max-width: 640px) {
.tutorial-overlay.tutorial-v2 .tutorial-cable { display: none; }
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
width: calc(100vw - 24px);
max-width: 360px;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tutorial-overlay.tutorial-v2 .tutorial-cable line { animation: none; }
.tutorial-overlay.tutorial-v2 .tutorial-tooltip { animation: none; }
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner { animation: none; }
.tutorial-overlay.tutorial-v2 .tutorial-ring { transition: none; }
}
+28 -7
View File
@@ -16,6 +16,8 @@ import { initCardGlare } from './core/card-glare.ts';
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
import { initBgShaders } from './core/bg-shaders.ts';
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
import { initModMenu } from './core/mod-menu.ts';
import { toggleCardHidden } from './core/card-sections.ts';
// Layer 2: ui
import {
@@ -32,6 +34,7 @@ import {
import {
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.ts';
@@ -55,6 +58,7 @@ import {
openDashboardCustomize, closeDashboardCustomize,
} from './features/dashboard-customize.ts';
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
import { startNotificationsWatcher } from './features/notifications-watcher.ts';
import { startEntityEventListeners } from './core/entity-events.ts';
import {
startPerfPolling, stopPerfPolling, setPerfMode,
@@ -62,7 +66,7 @@ import {
import {
loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, openTestDisplayPicker,
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
cloneAudioTemplate, onAudioEngineChange,
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
@@ -103,7 +107,7 @@ import {
} from './features/integrations.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset,
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
@@ -127,7 +131,6 @@ import {
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
@@ -220,7 +223,9 @@ import {
openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel,
loadShutdownAction, setShutdownAction,
saveExternalUrl, getBaseOrigin, loadExternalUrl,
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -238,6 +243,11 @@ Object.assign(window, {
// core / state (for inline script)
setApiKey,
// mod-card menu — referenced by inline onclick on .mod-menu__item
// for the "Hide card" entry. The handler uses the registered
// CardSection's section key (e.g. 'led-devices') + the card id.
toggleCardHidden,
// visual effects (called from inline <script>)
_updateBgAnimAccent: updateBgAnimAccent,
_updateBgAnimTheme: updateBgAnimTheme,
@@ -280,6 +290,7 @@ Object.assign(window, {
startTargetsTutorial,
startSourcesTutorial,
startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial,
tutorialNext,
tutorialPrev,
@@ -329,6 +340,7 @@ Object.assign(window, {
closeTestTemplateModal,
onEngineChange,
runTemplateTest,
openTestDisplayPicker,
updateCaptureDuration,
showAddStreamModal,
editStream,
@@ -407,7 +419,7 @@ Object.assign(window, {
deleteAutomation,
copyWebhookUrl,
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
// scene presets modal buttons + mod-card inline handlers
openScenePresetCapture,
editScenePreset,
saveScenePreset,
@@ -415,6 +427,7 @@ Object.assign(window, {
activateScenePreset,
cloneScenePreset,
deleteScenePreset,
recaptureScenePreset,
addSceneTarget,
// integrations
@@ -476,8 +489,6 @@ Object.assign(window, {
onCSSClockChange,
onAnimationTypeChange,
onDaylightRealTimeChange,
colorCycleAddColor,
colorCycleRemoveColor,
compositeAddLayer,
compositeRemoveLayer,
mappedAddZone,
@@ -618,7 +629,12 @@ Object.assign(window, {
setLogLevel,
loadShutdownAction,
setShutdownAction,
loadDaylightTimezone,
saveDaylightTimezone,
requestNotifPermissionFromSettings,
testNotifFromSettings,
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
// update
@@ -733,6 +749,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize visual effects
initCardGlare();
initModMenu();
initBgAnim();
initBgShaders();
initAppearance();
@@ -817,6 +834,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// Start global events WebSocket and auto-refresh
startEventsWS();
startEntityEventListeners();
// Device-event notifications (snack/Web Notification per user prefs).
// Must start *after* startEventsWS so its DOM listeners catch the
// first events fired during the startup grace window.
startNotificationsWatcher().catch(() => {});
startAutoRefresh();
// Perf poll starts globally so the transport-bar CPU / Mem cells stay
// live regardless of which tab is active. Tab-hidden pauses it via the
@@ -11,6 +11,7 @@
import { t } from './i18n.ts';
import { showConfirm } from './ui.ts';
import { ICON_LIST_CHECKS, ICON_CIRCLE_OFF, ICON_X } from './icons.ts';
import type { CardSection } from './card-sections.ts';
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
@@ -53,28 +54,40 @@ function _render() {
if (!section) { el.classList.remove('visible'); return; }
const count = section._selected.size;
const total = section._visibleCardCount();
const actions = section.bulkActions || [];
const allSelected = count > 0 && count === total;
const noneSelected = count === 0;
const actionBtns = actions.map(a => {
const cls = a.style === 'danger' ? 'btn btn-icon btn-danger bulk-action-btn' : 'btn btn-icon btn-secondary bulk-action-btn';
const label = t(a.labelKey);
const inner = a.icon || label;
return `<button class="${cls}" data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
const disabled = noneSelected ? ' disabled aria-disabled="true"' : '';
return `<button class="${cls}"${disabled} data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
}).join('');
// Two distinct icon buttons replace the previous tri-state checkbox so
// the affordance is unambiguous: pick everything, or clear the picks.
const selectAllDisabled = allSelected ? ' disabled aria-disabled="true"' : '';
const deselectAllDisabled = noneSelected ? ' disabled aria-disabled="true"' : '';
el.innerHTML = `
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
</label>
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
<div class="bulk-pick">
<button class="bulk-pick-btn" data-bulk-pick="all"${selectAllDisabled} title="${t('bulk.select_all')}" aria-label="${t('bulk.select_all')}">${ICON_LIST_CHECKS}</button>
<button class="bulk-pick-btn" data-bulk-pick="none"${deselectAllDisabled} title="${t('bulk.deselect_all')}" aria-label="${t('bulk.deselect_all')}">${ICON_CIRCLE_OFF}</button>
</div>
<span class="bulk-count" aria-live="polite">${t('bulk.selected_count', { count })}<small class="bulk-count-total"> / ${total}</small></span>
<div class="bulk-actions">${actionBtns}</div>
<button class="bulk-close" title="${t('bulk.cancel')}">&#x2715;</button>
<button class="bulk-close" title="${t('bulk.cancel')}" aria-label="${t('bulk.cancel')}">${ICON_X}</button>
`;
// Select All checkbox
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
if ((e.target as HTMLInputElement).checked) section.selectAll();
else section.deselectAll();
// Pick buttons
el.querySelector('[data-bulk-pick="all"]')!.addEventListener('click', () => {
section.selectAll();
});
el.querySelector('[data-bulk-pick="none"]')!.addEventListener('click', () => {
section.deselectAll();
});
// Action buttons
+165 -26
View File
@@ -21,6 +21,8 @@
import { createColorPicker, registerColorPicker } from './color-picker.ts';
import { ICON_TRASH } from './icons.ts';
import { renderModCardInner } from './mod-card.ts';
import type { ModCardOpts } from './mod-card.ts';
const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
@@ -63,6 +65,25 @@ export function setCardColor(id: string, hex: string): void {
const card = el as HTMLElement;
if (hex) card.style.setProperty('--ch', hex);
else card.style.removeProperty('--ch');
// Mod-card variant: also sync the leading colour-dot inside
// .mod-badge. The picker's `_cpPick` already inlined a
// background:hex on the swatch — clear it so the dot reverts
// to its channel-color default when the user resets.
const dot = card.querySelector(`#cp-swatch-cc-${escaped}`) as HTMLElement | null;
if (dot && dot.classList.contains('mod-badge__color')) {
if (hex) {
dot.dataset.custom = '1';
dot.style.setProperty('--user-color', hex);
// Clear any inline `background:hex` set by _cpPick so the
// CSS [data-custom] rule wins (background = var(--user-color)).
dot.style.removeProperty('background');
} else {
delete dot.dataset.custom;
dot.style.removeProperty('--user-color');
dot.style.removeProperty('background');
}
}
});
}
@@ -94,36 +115,97 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
}
/**
* Mod-card variant of `cardColorButton` emits the leading
* `.mod-badge__color` dot inside a `.mod-badge`. The dot IS the
* picker swatch (same id as the legacy swatch so `_cpToggle()` finds
* it). The popover is appended as a sibling of the badge so it can
* detach to <body> with fixed positioning when needed (the existing
* picker logic in color-picker.ts handles that automatically).
*
* Returns inline HTML to be injected as the first child of
* `.mod-badge`. No surrounding span the dot is the badge's leading
* element directly.
*/
export function cardColorDot(entityId: string, cardAttr: string): string {
const color = getCardColor(entityId);
const pickerId = `cc-${entityId}`;
registerColorPicker(pickerId, (hex) => {
setCardColor(entityId, hex);
});
// The dot doubles as the picker swatch. Inline `--user-color` is
// applied only when the user has picked a personal hue; absent it,
// the dot inherits the channel colour from the parent badge via
// the `.mod-badge__color` rule in cards.css.
const customAttr = color ? ` data-custom="1" style="--user-color:${color}"` : '';
const dataAttr = ` data-card-attr="${cardAttr}"`;
return `<span class="color-picker-wrapper mod-badge__color-wrap" id="cp-wrap-${entityId}">` +
`<span class="mod-badge__color color-picker-swatch" id="cp-swatch-${pickerId}" tabindex="0" role="button" aria-label="${getCardColorAriaLabel()}" onclick="event.stopPropagation(); window._cpToggle('${pickerId}')"${customAttr}${dataAttr}></span>` +
_colorPopoverHtml(pickerId, color || DEFAULT_SWATCH) +
`</span>`;
}
/** Build just the popover portion (the swatch is rendered separately
* by `cardColorDot`). Mirrors `createColorPicker`'s popover output. */
function _colorPopoverHtml(pickerId: string, currentColor: string): string {
// Reuse createColorPicker's full output but strip the wrapping
// .color-picker-wrapper and the legacy round swatch — we only need
// the popover. Easier: render the full widget into a temporary
// string and extract the popover. But that adds complexity. Since
// the popover markup is stable, inline it here.
const PRESETS = ['#4CAF50', '#7C4DFF', '#FF6D00', '#E91E63', '#00BCD4', '#FF5252', '#26A69A', '#2196F3', '#FFC107'];
const dots = PRESETS.map(c => {
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${pickerId}','${c}')"></button>`;
}).join('');
return `<div class="color-picker-popover anchor-left" id="cp-pop-${pickerId}" style="display:none" onclick="event.stopPropagation()">` +
`<div class="color-picker-grid">${dots}</div>` +
`<div class="color-picker-custom" onclick="this.querySelector('input').click()">` +
`<input type="color" id="cp-native-${pickerId}" value="${currentColor}" ` +
`oninput="event.stopPropagation(); window._cpPick('${pickerId}',this.value)" ` +
`onchange="event.stopPropagation(); window._cpPick('${pickerId}',this.value)">` +
`<span>Custom</span>` +
`</div>` +
`<div class="color-picker-reset" onclick="event.stopPropagation(); window._cpReset('${pickerId}','${DEFAULT_SWATCH}')"><span>Reset</span></div>` +
`</div>`;
}
function getCardColorAriaLabel(): string {
// i18n is not always loaded when this module evaluates (renders
// happen during early page boot). Fall back to English.
return (window as any).__t?.('common.card_color') || 'Card color';
}
/**
* Build a standard card shell with color support.
*
* Provides consistent structure across all card types:
* - .card-top-actions: remove button + optional extra top buttons
* - Bottom actions: action buttons + color picker (always last)
* - Automatic border-left color from localStorage
* Two rendering paths:
*
* @param {object} opts
* @param {'card'|'template-card'} [opts.type='card'] Card CSS class
* @param {string} opts.dataAttr Data attribute name, e.g. 'data-device-id'
* @param {string} opts.id Entity ID value
* @param {string} [opts.classes] Extra CSS classes on root element
* @param {string} [opts.topButtons] HTML for extra top-right buttons (power, autostart)
* @param {string} opts.removeOnclick onclick handler string for remove button
* @param {string} opts.removeTitle title attribute for remove button
* @param {string} opts.content Inner HTML (header, props, metrics, etc.)
* @param {string} opts.actions Action button HTML (without wrapper div)
* 1. Legacy (content/actions) emits `.card-top-actions` with the
* remove button and a bottom `.card-actions` row with action
* buttons + colour picker. Used by all cards that haven't been
* migrated to the modular system yet.
*
* 2. Modular (`mod`) emits the dashboard's `.mod-head / .mod-body /
* .mod-foot` markup with a kebab overflow menu housing duplicate /
* hide / delete. Card-color picker is integrated into the
* `.mod-badge` as a leading dot. Use this for any new card and
* when migrating existing card builders.
*
* Old callers keep working unchanged. New callers pass `mod`.
*/
export function wrapCard({
type = 'card',
dataAttr,
id,
classes = '',
topButtons = '',
removeOnclick,
removeTitle,
content,
actions,
}: {
export function wrapCard(opts: WrapCardLegacyOpts | WrapCardModOpts): string {
if ('mod' in opts && opts.mod) {
return _renderModCard(opts as WrapCardModOpts);
}
return _renderLegacyCard(opts as WrapCardLegacyOpts);
}
export interface WrapCardLegacyOpts {
type?: 'card' | 'template-card';
dataAttr: string;
id: string;
@@ -133,7 +215,28 @@ export function wrapCard({
removeTitle: string;
content: string;
actions: string;
}): string {
mod?: undefined;
}
export interface WrapCardModOpts {
type?: 'card' | 'template-card';
dataAttr: string;
id: string;
classes?: string;
mod: ModCardOpts;
}
function _renderLegacyCard({
type = 'card',
dataAttr,
id,
classes = '',
topButtons = '',
removeOnclick,
removeTitle,
content,
actions,
}: WrapCardLegacyOpts): string {
const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
const colorStyle = cardColorStyle(id);
return `
@@ -149,3 +252,39 @@ export function wrapCard({
</div>
</div>`;
}
function _renderModCard({
type = 'card',
dataAttr,
id,
classes = '',
mod,
}: WrapCardModOpts): string {
// mod-card.ts and card-colors.ts have a cyclic import (mod-card uses
// cardColorDot, card-colors uses renderModCardInner). The cycle is
// safe because both modules only call each other inside function
// bodies — never during module initialization. ESM live bindings
// resolve correctly by the time these functions actually run.
const inner = renderModCardInner(_withColorBindings(mod, id, dataAttr));
const colorStyle = cardColorStyle(id);
const runningCls = mod.running ? ' card-running is-running' : '';
const colorAttr = colorStyle ? ` style="${colorStyle}" data-has-color="1"` : '';
return `
<div class="${type} mod-card${classes ? ' ' + classes : ''}${runningCls}" ${dataAttr}="${id}"${colorAttr}>
${inner}
</div>`;
}
/** Inject entityId/cardAttr into the head opts so the badge dot can
* register its picker without callers needing to pass the same id
* twice. */
function _withColorBindings(mod: ModCardOpts, id: string, dataAttr: string): ModCardOpts {
return {
...mod,
head: {
...mod.head,
entityId: mod.head.entityId ?? id,
cardAttr: mod.head.cardAttr ?? dataAttr,
},
};
}
@@ -23,7 +23,7 @@
import { t } from './i18n.ts';
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF } from './icons.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts';
export interface BulkAction {
key: string;
@@ -316,30 +316,46 @@ export class CardSection {
});
}
// Card click delegation for selection
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
// Card click delegation for selection.
//
// When in selection mode, the entire card becomes the click
// target — clicking anywhere on it toggles selection. Inner
// controls (buttons, sliders, kebab, color dot) are visually
// disabled via `.cs-selecting *` pointer-events:none in CSS,
// so events pass through to the card root.
//
// Outside selection mode, Ctrl/Cmd+Click on the card body
// (not on a button/input) auto-enters bulk mode and selects
// that card.
content.addEventListener('click', (e: MouseEvent) => {
if (!this.keyAttr) return;
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
if (!card) return;
// Don't hijack clicks on buttons, links, inputs inside cards
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
// Auto-enter selection mode on Ctrl/Cmd+Click
if (!this._selecting && (e.ctrlKey || e.metaKey)) {
if (this._selecting) {
// pointer-events:none on descendants means events
// already bypass inner controls; just toggle.
const key = card.getAttribute(this.keyAttr);
if (!key) return;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
return;
}
// Not selecting — only auto-enter on Ctrl/Cmd+Click
// outside any interactive control.
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper, .mod-menu-wrap, .mod-fader')) return;
if (e.ctrlKey || e.metaKey) {
this.enterSelectionMode();
}
if (!this._selecting) return;
const key = card.getAttribute(this.keyAttr);
if (!key) return;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
const key = card.getAttribute(this.keyAttr);
if (!key) return;
this._toggleSelect(key);
this._lastClickedKey = key;
}
this._lastClickedKey = key;
});
// Escape to exit selection mode
@@ -358,6 +374,7 @@ export class CardSection {
if (this.keyAttr) {
this._injectHideButtons(content);
this._injectDragHandles(content);
this._injectBulkTicks(content);
this._initDrag(content);
}
@@ -463,10 +480,11 @@ export class CardSection {
}
}
// Re-inject hide buttons and drag handles on new/replaced cards
// Re-inject hide buttons, drag handles, bulk ticks on new/replaced cards
if (this.keyAttr && (added.size > 0 || replaced.size > 0)) {
this._injectHideButtons(content);
this._injectDragHandles(content);
this._injectBulkTicks(content);
}
// Re-cache searchable text for new/replaced cards
@@ -638,28 +656,31 @@ export class CardSection {
});
}
_injectCheckboxes(content: HTMLElement) {
_injectCheckboxes(_content: HTMLElement) {
// Selection mode now uses the card root as the click target —
// no per-card checkbox is injected. The visual indicator is the
// `.card-selected` border + box-shadow defined in CSS, plus
// pointer-events:none on descendants so the entire card behaves
// as one big toggle. Kept as a no-op so existing callsites in
// bind() / reconcile() don't need changes.
}
/** Inject a single .mod-bulk-tick element per card. Hidden by CSS
* unless the card is `.card-selected` AND the section is
* `.cs-selecting` so it costs nothing visually outside bulk mode
* and renders a corner checkmark when both flags are on. */
_injectBulkTicks(content: HTMLElement) {
if (!this.keyAttr) return;
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
if (card.querySelector('.card-bulk-check')) return;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'card-bulk-check';
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
cb.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
const key = card.getAttribute(this.keyAttr)!;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
});
// Insert as first child of .card-top-actions, or prepend to card
const topActions = card.querySelector('.card-top-actions');
if (topActions) topActions.prepend(cb);
else card.prepend(cb);
// Strip any legacy injected checkboxes from older deploys
const stale = card.querySelector('.card-bulk-check');
if (stale) stale.remove();
if (card.querySelector('.mod-bulk-tick')) return;
const tick = document.createElement('span');
tick.className = 'mod-bulk-tick';
tick.setAttribute('aria-hidden', 'true');
tick.innerHTML = ICON_CHECK;
card.appendChild(tick);
});
}
@@ -0,0 +1,255 @@
/**
* Daylight-cycle timezone picker.
*
* Exposes:
* - `getTimezoneItems()` IconSelectItem[] for the EntityPalette / EntitySelect
* - `enhanceTimezoneSelect(target, value, onChange?)` replace a `<select>` with
* a refined EntitySelect picker. Idempotent; safe to call on every modal open.
* - `populateTimezoneSelect(id, value)` back-compat for legacy callers that
* still want a plain native `<select>`.
*
* Each entry exposes a friendly city name and the timezone's *current* UTC
* offset (computed via `Intl.DateTimeFormat`), so users see `Berlin · UTC+02:00`
* instead of bare IANA strings like `Europe/Berlin`.
*/
import { t } from './i18n.ts';
import {
ICON_GLOBE,
ICON_SPARKLES,
ICON_TARGET_ICON,
ICON_CLOCK,
} from './icons.ts';
import type { IconSelectItem } from './icon-select.ts';
import { EntitySelect } from './entity-palette.ts';
type RegionKey =
| 'utc'
| 'europe'
| 'africa'
| 'me'
| 'asia'
| 'pacific'
| 'americas';
interface TimezoneEntry {
iana: string;
region: RegionKey;
city: string;
}
const TIMEZONES: ReadonlyArray<TimezoneEntry> = [
{ iana: 'UTC', region: 'utc', city: 'UTC' },
// Europe
{ iana: 'Europe/London', region: 'europe', city: 'London' },
{ iana: 'Europe/Lisbon', region: 'europe', city: 'Lisbon' },
{ iana: 'Europe/Paris', region: 'europe', city: 'Paris' },
{ iana: 'Europe/Berlin', region: 'europe', city: 'Berlin' },
{ iana: 'Europe/Warsaw', region: 'europe', city: 'Warsaw' },
{ iana: 'Europe/Helsinki', region: 'europe', city: 'Helsinki' },
{ iana: 'Europe/Athens', region: 'europe', city: 'Athens' },
{ iana: 'Europe/Moscow', region: 'europe', city: 'Moscow' },
{ iana: 'Europe/Istanbul', region: 'europe', city: 'Istanbul' },
// Africa & Middle East
{ iana: 'Africa/Cairo', region: 'africa', city: 'Cairo' },
{ iana: 'Africa/Lagos', region: 'africa', city: 'Lagos' },
{ iana: 'Africa/Johannesburg', region: 'africa', city: 'Johannesburg' },
{ iana: 'Asia/Dubai', region: 'me', city: 'Dubai' },
{ iana: 'Asia/Tehran', region: 'me', city: 'Tehran' },
// Asia
{ iana: 'Asia/Karachi', region: 'asia', city: 'Karachi' },
{ iana: 'Asia/Kolkata', region: 'asia', city: 'Kolkata' },
{ iana: 'Asia/Bangkok', region: 'asia', city: 'Bangkok' },
{ iana: 'Asia/Jakarta', region: 'asia', city: 'Jakarta' },
{ iana: 'Asia/Singapore', region: 'asia', city: 'Singapore' },
{ iana: 'Asia/Hong_Kong', region: 'asia', city: 'Hong Kong' },
{ iana: 'Asia/Shanghai', region: 'asia', city: 'Shanghai' },
{ iana: 'Asia/Seoul', region: 'asia', city: 'Seoul' },
{ iana: 'Asia/Tokyo', region: 'asia', city: 'Tokyo' },
// Pacific
{ iana: 'Australia/Perth', region: 'pacific', city: 'Perth' },
{ iana: 'Australia/Adelaide', region: 'pacific', city: 'Adelaide' },
{ iana: 'Australia/Sydney', region: 'pacific', city: 'Sydney' },
{ iana: 'Pacific/Auckland', region: 'pacific', city: 'Auckland' },
{ iana: 'Pacific/Honolulu', region: 'pacific', city: 'Honolulu' },
// Americas
{ iana: 'America/Anchorage', region: 'americas', city: 'Anchorage' },
{ iana: 'America/Los_Angeles', region: 'americas', city: 'Los Angeles' },
{ iana: 'America/Denver', region: 'americas', city: 'Denver' },
{ iana: 'America/Chicago', region: 'americas', city: 'Chicago' },
{ iana: 'America/Mexico_City', region: 'americas', city: 'Mexico City' },
{ iana: 'America/New_York', region: 'americas', city: 'New York' },
{ iana: 'America/Toronto', region: 'americas', city: 'Toronto' },
{ iana: 'America/Sao_Paulo', region: 'americas', city: 'São Paulo' },
{ iana: 'America/Argentina/Buenos_Aires', region: 'americas', city: 'Buenos Aires' },
];
const REGION_FALLBACK: Record<RegionKey, string> = {
utc: 'UTC',
europe: 'Europe',
africa: 'Africa',
me: 'Middle East',
asia: 'Asia',
pacific: 'Pacific',
americas: 'Americas',
};
function _detectedTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
return '';
}
}
/** Compute the current UTC offset for a given IANA zone, formatted as `UTC+02:00`. */
function _utcOffsetLabel(iana: string): string {
try {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: iana,
timeZoneName: 'longOffset',
});
const parts = fmt.formatToParts(new Date());
const raw = parts.find(p => p.type === 'timeZoneName')?.value || '';
// Intl returns 'GMT+02:00' for offsets and 'GMT' for UTC. Normalize.
if (raw === 'GMT' || raw === '') return 'UTC';
return raw.replace(/^GMT/, 'UTC');
} catch {
return '';
}
}
function _friendlyCityFromIana(iana: string): string {
const last = iana.split('/').pop() || iana;
return last.replace(/_/g, ' ');
}
function _regionLabel(region: RegionKey): string {
return t(`common.daylight_tz.region.${region}`) || REGION_FALLBACK[region];
}
function _formatRowLabel(city: string, offset: string): string {
// Em-dash separator, monospaced offset rendered via CSS on the popup.
return offset ? `${city} · ${offset}` : city;
}
/** Build the list of items for an EntitySelect timezone picker. */
export function getTimezoneItems(): IconSelectItem[] {
const items: IconSelectItem[] = [];
const detected = _detectedTimezone();
items.push({
value: '',
icon: ICON_TARGET_ICON,
label: t('common.daylight_tz.system') || 'System default',
desc: t('common.daylight_tz.system_desc') || 'Server clock',
});
if (detected) {
const offset = _utcOffsetLabel(detected);
const city = _friendlyCityFromIana(detected);
const detectedLabel = t('common.daylight_tz.detected_label') || 'Auto-detected';
items.push({
value: detected,
icon: ICON_SPARKLES,
label: _formatRowLabel(city, offset),
desc: `${detectedLabel} · ${detected}`,
});
}
for (const tz of TIMEZONES) {
if (detected && tz.iana === detected) continue;
const offset = _utcOffsetLabel(tz.iana);
const region = _regionLabel(tz.region);
items.push({
value: tz.iana,
icon: tz.region === 'utc' ? ICON_CLOCK : ICON_GLOBE,
label: _formatRowLabel(tz.city, offset),
desc: `${region} · ${tz.iana}`,
});
}
return items;
}
/**
* Populate a `<select>` with timezone options. Kept for back-compat; the new
* preferred entrypoint is `enhanceTimezoneSelect()`.
*/
export function populateTimezoneSelect(selectId: string, currentValue: string): void {
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!select) return;
_syncNativeOptions(select, currentValue);
}
function _syncNativeOptions(select: HTMLSelectElement, currentValue: string): void {
const items = getTimezoneItems();
const seen = new Set<string>();
const fragments: string[] = [];
for (const item of items) {
if (seen.has(item.value)) continue;
seen.add(item.value);
const text = item.desc ? `${item.label}${item.desc}` : item.label;
fragments.push(`<option value="${_escapeAttr(item.value)}">${_escapeText(text)}</option>`);
}
if (currentValue && !seen.has(currentValue)) {
fragments.unshift(
`<option value="${_escapeAttr(currentValue)}">${_escapeText(currentValue)}</option>`,
);
}
select.innerHTML = fragments.join('');
select.value = currentValue || '';
}
function _escapeAttr(s: string): string {
return s.replace(/[&<>"']/g, c =>
c === '&' ? '&amp;'
: c === '<' ? '&lt;'
: c === '>' ? '&gt;'
: c === '"' ? '&quot;'
: '&#39;',
);
}
function _escapeText(s: string): string {
return _escapeAttr(s);
}
const _enhanced = new WeakMap<HTMLSelectElement, EntitySelect>();
/**
* Replace a native `<select>` with the refined timezone EntitySelect picker.
* Idempotent calling on the same element again refreshes items and value.
*/
export function enhanceTimezoneSelect(
target: HTMLSelectElement,
currentValue: string,
onChange?: (value: string) => void,
): EntitySelect {
_syncNativeOptions(target, currentValue);
const existing = _enhanced.get(target);
if (existing) {
existing.refresh();
existing.setValue(currentValue || '');
return existing;
}
const trigger = target.parentElement?.classList.contains('tz-picker-wrap');
if (trigger) {
// Hint for stylesheet: distinguish the timezone trigger from generic ones.
target.parentElement!.dataset.tzPicker = '';
}
const es = new EntitySelect({
target,
getItems: getTimezoneItems,
placeholder: t('common.daylight_tz.search') || 'Search timezones…',
onChange,
});
_enhanced.set(target, es);
return es;
}
@@ -92,7 +92,7 @@ const KIND_ICONS = {
// ── Subtype-specific icon overrides ──
const SUBTYPE_ICONS = {
color_strip_source: {
picture_advanced: P.monitor, static: P.palette, color_cycle: P.refreshCw,
picture_advanced: P.monitor, static: P.palette,
gradient: P.rainbow, effect: P.zap, composite: P.link,
mapped: P.mapPin, mapped_zones: P.mapPin,
audio: P.music, audio_visualization: P.music,
+6 -4
View File
@@ -98,12 +98,14 @@ export function changeLocale() {
}
}
/** Build locale items for the IconSelect. Uses 2-letter code badge as icon. */
/** Build locale items for the IconSelect. The 2-letter code is the only label
* long-form names (English / Русский / ) were redundant when the code is
* already an unambiguous identifier. Empty `icon` is hidden by CSS via :empty. */
function _getLocaleItems(): { value: string; icon: string; label: string }[] {
return [
{ value: 'en', icon: '<span style="font-weight:700">EN</span>', label: 'English' },
{ value: 'ru', icon: '<span style="font-weight:700">RU</span>', label: 'Русский' },
{ value: 'zh', icon: '<span style="font-weight:700">ZH</span>', label: '中文' },
{ value: 'en', icon: '', label: 'EN' },
{ value: 'ru', icon: '', label: 'RU' },
{ value: 'zh', icon: '', label: 'ZH' },
];
}
@@ -81,6 +81,7 @@ export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d=
export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>';
export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>';
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
export const moreHorizontal = '<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/>';
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
@@ -123,3 +124,13 @@ export const chevronDown = '<path d="m6 9 6 6 6-6"/>';
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
// Lucide: git-merge (sequence mode icon)
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
export const easingLinear = '<path d="M4 20 20 4"/>';
export const easingStep = '<path d="M4 20h8v-8h8V4"/>';
export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
@@ -87,6 +87,19 @@ export class IconSelect {
this._searchable = searchable;
this._searchPlaceholder = searchPlaceholder;
// Ensure the native select has an <option> for every item so .value can
// be set programmatically. HTML-authored selects sometimes ship empty,
// and assigning .value to a select with no matching option is a no-op,
// which leaves the trigger label blank.
if (this._select.options.length === 0) {
for (const item of this._items) {
const opt = document.createElement('option');
opt.value = item.value;
opt.textContent = item.label;
this._select.appendChild(opt);
}
}
// Hide the native select
this._select.style.display = 'none';
+4 -1
View File
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor),
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
static: _svg(P.palette), gradient: _svg(P.rainbow),
effect: _svg(P.zap), composite: _svg(P.link),
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: _svg(P.music), audio_visualization: _svg(P.music),
@@ -269,6 +269,7 @@ export const ICON_AUDIO_INPUT = _svg(P.mic);
export const ICON_CLOCK = _svg(P.clock);
export const ICON_WARNING = _svg(P.triangleAlert);
export const ICON_OK = _svg(P.circleCheck);
export const ICON_CHECK = _svg(P.check);
export const ICON_LINK_SOURCE = _svg(P.tv);
export const ICON_LED = _svg(P.lightbulb);
export const ICON_FPS = _svg(P.zap);
@@ -318,6 +319,7 @@ export const ICON_FAST_FORWARD = _svg(P.fastForward);
export const ICON_ROTATE_CW = _svg(P.rotateCw);
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
export const ICON_DOWNLOAD = _svg(P.download);
export const ICON_HARD_DRIVE = _svg(P.hardDrive);
export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera);
@@ -330,6 +332,7 @@ export const ICON_KEYBOARD = _svg(P.keyboard);
export const ICON_MOUSE = _svg(P.mouse);
export const ICON_HEADPHONES = _svg(P.headphones);
export const ICON_TRASH = _svg(P.trash2);
export const ICON_KEBAB = _svg(P.moreHorizontal);
export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
@@ -0,0 +1,389 @@
/**
* Mod-card renderers structured helpers that emit the dashboard's
* `.mod-*` markup vocabulary on entity cards.
*
* Background: dashboard.css already ships the `.mod-head / .mod-id /
* .mod-badge / .mod-leds / .mod-metrics / .mod-foot / .mod-patch /
* .mod-btn` system. This module makes that same vocabulary callable
* from any feature's `create*Card()` builder, so device, target,
* automation, source, etc. cards can converge on the same visual
* language used by the Dashboard tab.
*
* Used by the new `wrapCard({ mod })` path in `card-colors.ts`.
*/
import { t } from './i18n.ts';
import { escapeHtml } from './api.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB } from './icons.ts';
import { cardColorDot } from './card-colors.ts';
export type LedState = 'on' | 'off' | 'blink' | 'fault';
export interface ModBadgeOpts {
/** Mono-caps type label, e.g. "LED · CH-01" or "FFT · IN" */
text: string;
/** Emit a leading colour-picker dot inside the badge (default true).
* Set false for cards that don't carry a personal accent. */
colorDot?: boolean;
}
export interface ModMetricOpts {
/** Caps label */
k: string;
/** Display-font value (HTML allowed for <small> / <span> etc.) */
v: string;
/** Add 'signal' modifier so the value tints to --ch */
accent?: boolean;
/** Mark the cell as having errors (coral tint) */
error?: boolean;
/** Tooltip on the cell */
title?: string;
/** Inline icon next to the label */
icon?: string;
/** Optional id on the .v element for live updates */
valueId?: string;
/** Visual variant for the cell 'text-stack' renders the value in
* mono font with optional `<span class="v-sub">` block for a
* secondary line (e.g. LED chip + RGB/RGBW). The default display
* font is too coarse for string identifiers. */
variant?: 'text-stack';
/** Extra raw HTML appended after the .v element inside the cell
* used to embed a sparkline canvas (`.mod-metric-spark-canvas`)
* into the FPS metric, mirroring the dashboard pattern. */
extra?: string;
/** Extra data attributes on the .v element (e.g. data-fps-text for
* cached lookup during live updates). */
valueDataAttrs?: Record<string, string>;
}
export interface ModChipOpts {
/** Visible text */
text: string;
/** Inline icon HTML before the text */
icon?: string;
/** Click handler — triggers the link variant if set */
onclick?: string;
/** "tag" (filled), "err" (coral), or "default" */
variant?: 'default' | 'tag' | 'err';
/** Tooltip */
title?: string;
}
export interface ModFaderOpts {
/** Mono-caps label, e.g. "Bright" */
label: string;
/** Current value (0..max). Displayed as raw integer. */
value: number;
/** Maximum value (e.g. 255). Defaults to 100. */
max?: number;
/** Optional id on the slider for binding */
sliderId?: string;
/** oninput handler — receives `this.value` */
oninput?: string;
/** onchange handler — receives `this.value` */
onchange?: string;
/** Optional data attributes on the slider */
dataAttrs?: Record<string, string>;
/** Disable the fader */
disabled?: boolean;
}
export interface ModFootOpts {
/** Patch indicator state — controls the dot style */
patchState?: 'live' | 'standby' | 'offline' | 'idle';
/** Patch label — short caps text, e.g. "PATCHED · OUT-1" */
patchLabel?: string;
/** Primary action button (left of any secondary actions) */
primaryAction?: ModBtnOpts;
/** Secondary text-button(s) */
secondaryActions?: ModBtnOpts[];
/** Tertiary icon-only button(s) — settings, edit, etc. */
iconActions?: ModBtnOpts[];
}
export interface ModBtnOpts {
/** Visible label (omitted for icon-only variant) */
label?: string;
/** Icon HTML */
icon?: string;
/** onclick handler */
onclick: string;
/** Tooltip */
title?: string;
/** Variant */
variant?: 'go' | 'stop' | 'default';
/** Make this an icon-only button (.mod-btn-icon) */
iconOnly?: boolean;
/** i18n keys for runtime translation */
i18nTitle?: string;
/** Extra data attributes (e.g. for live-update binding) */
dataAttrs?: Record<string, string>;
}
export interface ModMenuItemOpts {
/** Visible text */
label: string;
/** Icon HTML */
icon?: string;
/** Inline onclick. The menu auto-closes on click. */
onclick: string;
/** Mark as destructive (coral colour, separator above) */
danger?: boolean;
}
export interface ModMenuOpts {
/** Append a Duplicate item (default true if duplicateOnclick provided) */
duplicateOnclick?: string;
/** Append a Hide-card item (default true). The handler should toggle
* the entity's hidden state via the existing CardSection helpers. */
hideOnclick?: string;
/** Append a destructive Delete item (with separator above). When
* omitted, no Delete option appears (e.g. built-in entities). */
deleteOnclick?: string;
/** Custom menu items inserted before the standard set */
extraItems?: ModMenuItemOpts[];
}
export interface ModHeadOpts {
badge: ModBadgeOpts;
/** Card title — escaped before rendering */
name: string;
/** Optional secondary line under the title plain text, escaped.
* Use `metaHtml` instead when you need a clickable link or other
* inline markup. Mutually exclusive with `metaHtml`. */
meta?: string;
/** Raw HTML version of `meta`. Caller is responsible for escaping
* any user-controlled substrings before passing in. */
metaHtml?: string;
/** 03 LEDs in the recessed bezel. Hidden when empty. */
leds?: LedState[];
/** Health dot to inject inline beside the name (raw HTML) */
healthDot?: string;
/** Overflow menu options. Pass null to suppress the menu entirely
* (e.g. for read-only or system cards). */
menu?: ModMenuOpts | null;
/** Entity id required when colorDot is true so the picker can
* register against it. */
entityId?: string;
/** Data-attr name (e.g. 'data-device-id') used by the picker to
* propagate the chosen colour to every card representing this
* entity. */
cardAttr?: string;
}
export interface ModBodyOpts {
metrics?: ModMetricOpts[];
chips?: ModChipOpts[];
fader?: ModFaderOpts;
/** Raw HTML for a preview surface (gradient strip, asset thumb, LED preview) */
preview?: string;
/** Free-form description text shown below the head */
desc?: string;
/** Free-form raw HTML appended at the end of the body escape-hatch
* for live-updating widgets (sparkline canvases, swatch grids,
* collapsible detail blocks) that don't fit the predefined slots.
* Caller is responsible for HTML-escaping any user-controlled
* substrings. */
extraHtml?: string;
}
export interface ModCardOpts {
head: ModHeadOpts;
body?: ModBodyOpts;
foot?: ModFootOpts;
/** Mark the card as `is-running` */
running?: boolean;
}
// ─────────────────────────────────────────────────────────────────
// Renderers
// ─────────────────────────────────────────────────────────────────
function _ledsHtml(leds?: LedState[]): string {
if (!leds || leds.length === 0) return '';
const dots = leds.map(s => {
if (s === 'off') return '<span class="led"></span>';
if (s === 'fault') return '<span class="led fault"></span>';
if (s === 'blink') return '<span class="led on blink"></span>';
return '<span class="led on"></span>';
}).join('');
return `<div class="mod-leds" aria-hidden="true">${dots}</div>`;
}
function _menuHtml(menu: ModMenuOpts | null | undefined): string {
if (menu === null) return '';
const m = menu || {};
const items: string[] = [];
if (m.extraItems) {
for (const it of m.extraItems) {
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
}
}
if (m.duplicateOnclick) {
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.duplicateOnclick}">${ICON_CLONE} <span>${t('common.clone')}</span></button>`);
}
if (m.hideOnclick) {
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.hideOnclick}">${ICON_EYE_OFF} <span>${t('common.hide') || 'Hide'}</span></button>`);
}
if (m.deleteOnclick) {
if (items.length > 0) items.push('<div class="mod-menu__sep"></div>');
items.push(`<button type="button" class="mod-menu__item mod-menu__item--danger" role="menuitem" onclick="${m.deleteOnclick}">${ICON_TRASH} <span>${t('common.delete')}</span></button>`);
}
if (items.length === 0) return '';
return `<div class="mod-menu-wrap">
<button type="button" class="mod-menu-btn" aria-haspopup="true" aria-expanded="false" aria-label="${t('common.more_actions') || 'More actions'}" title="${t('common.more_actions') || 'More actions'}">${ICON_KEBAB}</button>
<div class="mod-menu" role="menu">${items.join('')}</div>
</div>`;
}
function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string): string {
const showDot = badge.colorDot !== false && entityId && cardAttr;
const dot = showDot ? cardColorDot(entityId!, cardAttr!) : '';
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
}
export function renderModHead(head: ModHeadOpts): string {
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
: head.meta ? `<div class="mod-meta">${escapeHtml(head.meta)}</div>`
: '';
const ledsHtml = _ledsHtml(head.leds);
const menuHtml = _menuHtml(head.menu);
// Order: id (flex:1) → kebab → LED bezel. LED status is the
// running/idle indicator and lives at the far-right corner where
// it doubles as the visual anchor of the head row. Kebab sits to
// its left as the second-most-discreet element.
return `<div class="mod-head">
<div class="mod-id">
${badgeHtml}
${nameHtml}
${metaHtml}
</div>
${menuHtml}
${ledsHtml}
</div>`;
}
export function renderModMetrics(metrics: ModMetricOpts[]): string {
if (!metrics.length) return '';
const cellsClass = metrics.length === 1 ? 'mod-metrics mod-metrics--1'
: metrics.length === 2 ? 'mod-metrics mod-metrics--2'
: 'mod-metrics';
const cells = metrics.map(m => {
const cellCls = [
'mod-metric',
m.error ? 'has-errors' : '',
m.variant ? `mod-metric--${m.variant}` : '',
].filter(Boolean).join(' ');
const titleAttr = m.title ? ` title="${escapeHtml(m.title)}"` : '';
const vCls = ['v', m.accent ? 'signal' : '', m.error ? 'has-errors' : ''].filter(Boolean).join(' ');
const vIdAttr = m.valueId ? ` id="${m.valueId}"` : '';
const vDataAttrs = m.valueDataAttrs
? ' ' + Object.entries(m.valueDataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const labelHtml = `<span class="k">${m.icon || ''} <span>${escapeHtml(m.k)}</span></span>`;
const extraHtml = m.extra || '';
return `<div class="${cellCls}"${titleAttr}>${labelHtml}<span class="${vCls}"${vIdAttr}${vDataAttrs}>${m.v}</span>${extraHtml}</div>`;
}).join('');
return `<div class="${cellsClass}">${cells}</div>`;
}
export function renderModChips(chips: ModChipOpts[]): string {
if (!chips.length) return '';
const items = chips.map(c => {
const variant = c.variant === 'tag' ? ' chip--tag'
: c.variant === 'err' ? ' chip--err'
: '';
const link = c.onclick ? ' chip--link' : '';
const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : '';
const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : '';
return `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
}).join('');
return `<div class="mod-chips">${items}</div>`;
}
export function renderModFader(f: ModFaderOpts): string {
const max = f.max || 100;
const pct = Math.round(Math.max(0, Math.min(1, f.value / max)) * 100);
const sliderId = f.sliderId ? ` id="${f.sliderId}"` : '';
const dataAttrs = f.dataAttrs
? Object.entries(f.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const oninputAttr = f.oninput ? ` oninput="${f.oninput}"` : '';
const onchangeAttr = f.onchange ? ` onchange="${f.onchange}"` : '';
const disabledAttr = f.disabled ? ' disabled' : '';
return `<div class="mod-fader">
<span class="mod-fader__k">${escapeHtml(f.label)}</span>
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div>
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}>
<span class="mod-fader__v">${f.value}</span>
</div>`;
}
function _btnHtml(b: ModBtnOpts): string {
const variant = b.variant === 'go' ? ' mod-btn-go'
: b.variant === 'stop' ? ' mod-btn-stop'
: '';
const icon = b.iconOnly ? ' mod-btn-icon' : '';
const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : '';
const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : '';
const labelHtml = b.label ? ` <span>${escapeHtml(b.label)}</span>` : '';
const dataAttrs = b.dataAttrs
? ' ' + Object.entries(b.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
return `<button type="button" class="mod-btn${variant}${icon}" onclick="${b.onclick}"${titleAttr}${i18nAttr}${dataAttrs}>${b.icon || ''}${labelHtml}</button>`;
}
export function renderModFoot(foot: ModFootOpts): string {
const liveCls = foot.patchState === 'live' ? ' is-live' : '';
const dimCls = foot.patchState === 'offline' ? ' is-offline' : '';
const patchHtml = foot.patchLabel
? `<div class="mod-patch"><span class="patch-dot${liveCls}${dimCls}"></span><span>${escapeHtml(foot.patchLabel)}</span></div>`
: '';
const buttons: string[] = [];
if (foot.primaryAction) buttons.push(_btnHtml(foot.primaryAction));
if (foot.secondaryActions) {
for (const b of foot.secondaryActions) buttons.push(_btnHtml(b));
}
if (foot.iconActions) {
for (const b of foot.iconActions) buttons.push(_btnHtml({ ...b, iconOnly: true }));
}
return `<div class="mod-foot">${patchHtml}${buttons.join('')}</div>`;
}
export function renderModBody(body: ModBodyOpts | undefined): string {
if (!body) return '';
const parts: string[] = [];
if (body.desc) parts.push(`<div class="mod-desc">${escapeHtml(body.desc)}</div>`);
if (body.metrics) parts.push(renderModMetrics(body.metrics));
if (body.preview) parts.push(`<div class="mod-preview">${body.preview}</div>`);
if (body.chips) parts.push(renderModChips(body.chips));
if (body.fader) parts.push(renderModFader(body.fader));
if (body.extraHtml) parts.push(body.extraHtml);
return parts.join('');
}
/**
* Compose a full mod-card body (head + optional body + foot).
* Used by `wrapCard({ mod })` callers normally don't invoke this
* directly.
*/
export function renderModCardInner(opts: ModCardOpts): string {
return [
renderModHead(opts.head),
renderModBody(opts.body),
opts.foot ? renderModFoot(opts.foot) : '',
].filter(Boolean).join('');
}
@@ -0,0 +1,172 @@
/**
* Mod-card overflow menu single document-level handler that opens
* the kebab dropdown on entity cards that adopt the `.mod-*` markup.
*
* The menu portal-detaches to <body> when opened so that:
* - the card's `overflow: hidden` doesn't clip the dropdown,
* - and a hovered card's `transform: translateY(-2px)` doesn't turn
* `position: fixed` into "positioned relative to the card" (a
* well-known browser behaviour where `position: fixed` is rooted
* in the nearest transformed ancestor instead of the viewport).
*
* On close, the menu is re-inserted at its original DOM position so
* subsequent reconciliations keep markup stable.
*
* Markup (emitted by `renderModMenu` in `mod-card.ts`):
*
* <div class="mod-menu-wrap">
* <button class="mod-menu-btn" type="button" aria-haspopup="true" aria-expanded="false"></button>
* <div class="mod-menu" role="menu">
* <button class="mod-menu__item" data-action="duplicate"></button>
* <button class="mod-menu__item" data-action="hide"></button>
* <div class="mod-menu__sep"></div>
* <button class="mod-menu__item mod-menu__item--danger" data-action="delete"></button>
* </div>
* </div>
*
* Action handlers are attached via inline onclick on the menu items
* themselves (matching the rest of the codebase's pattern).
*
* Initialised once from app.ts via `initModMenu()`.
*/
let _initialised = false;
let _controller: AbortController | null = null;
const MENU_VERTICAL_GAP = 4;
const MENU_VIEWPORT_MARGIN = 12;
interface OpenMenuInfo {
wrap: HTMLElement;
origParent: Element;
origNext: Node | null;
}
const _openMenus = new Map<HTMLElement, OpenMenuInfo>();
function _restoreMenu(menu: HTMLElement, info: OpenMenuInfo): void {
menu.classList.remove('is-open');
menu.style.top = '';
menu.style.right = '';
menu.style.left = '';
menu.style.transformOrigin = '';
info.wrap.classList.remove('is-open');
const btn = info.wrap.querySelector('.mod-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
if (info.origParent.isConnected) {
info.origParent.insertBefore(menu, info.origNext);
} else {
// Card was reconciled away while the menu was open — drop the
// detached menu rather than reattach to a dead parent.
menu.remove();
}
}
function _closeAll(): void {
_openMenus.forEach((info, menu) => _restoreMenu(menu, info));
_openMenus.clear();
}
function _openMenu(wrap: HTMLElement): void {
const btn = wrap.querySelector('.mod-menu-btn') as HTMLElement | null;
const menu = wrap.querySelector('.mod-menu') as HTMLElement | null;
if (!btn || !menu) return;
// Portal the menu to <body> so transformed/overflow-clipping
// ancestors don't capture or hide it. Track origin so we can
// restore on close.
const origParent = menu.parentElement!;
const origNext = menu.nextSibling;
document.body.appendChild(menu);
_openMenus.set(menu, { wrap, origParent, origNext });
wrap.classList.add('is-open');
menu.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
// Position after a paint so offsetHeight is measurable.
requestAnimationFrame(() => _positionMenu(menu, btn));
}
/** Anchor the fixed-position menu to the kebab button. Opens downward
* by default; flips upward when there isn't enough room below the
* trigger (e.g. cards near the viewport bottom). */
function _positionMenu(menu: HTMLElement, btn: HTMLElement): void {
const rect = btn.getBoundingClientRect();
const menuH = menu.offsetHeight || 140;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < menuH + MENU_VIEWPORT_MARGIN) {
// Open upward
menu.style.top = `${rect.top - menuH - MENU_VERTICAL_GAP}px`;
menu.style.transformOrigin = 'bottom right';
} else {
menu.style.top = `${rect.bottom + MENU_VERTICAL_GAP}px`;
menu.style.transformOrigin = 'top right';
}
menu.style.right = `${window.innerWidth - rect.right}px`;
menu.style.left = 'auto';
}
function _onClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
const btn = target.closest('.mod-menu-btn') as HTMLElement | null;
if (btn) {
e.stopPropagation();
const wrap = btn.closest('.mod-menu-wrap') as HTMLElement | null;
if (!wrap) return;
const wasOpen = wrap.classList.contains('is-open');
_closeAll();
if (!wasOpen) _openMenu(wrap);
return;
}
// A click on a menu item: let the inline onclick fire, then close.
const item = target.closest('.mod-menu__item') as HTMLElement | null;
if (item) {
// Use a short timeout so the inline onclick (e.g. a confirm prompt)
// executes before the menu disappears under the user's pointer.
setTimeout(_closeAll, 0);
return;
}
// Click outside any menu — close all.
if (!target.closest('.mod-menu')) _closeAll();
}
function _onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') _closeAll();
}
/**
* Initialise the mod-menu system once at app boot.
* Idempotent calling more than once is a no-op.
*/
export function initModMenu(): void {
if (_initialised) return;
_initialised = true;
_controller = new AbortController();
const { signal } = _controller;
document.addEventListener('click', _onClick, { signal });
document.addEventListener('keydown', _onKeydown, { signal });
// Close on scroll/resize so the fixed-position menu doesn't drift
// away from its anchor.
window.addEventListener('scroll', _closeAll, { passive: true, capture: true, signal });
window.addEventListener('resize', _closeAll, { passive: true, signal });
}
/** Tear down listeners (used by tests / hot reload). */
export function disposeModMenu(): void {
if (_controller) {
_controller.abort();
_controller = null;
}
_initialised = false;
_closeAll();
}
/** Manually close any open menu exposed for callers that need to
* dismiss a menu after a programmatic action (e.g. a confirm dialog
* closing). */
export function closeAllModMenus(): void {
_closeAll();
}
+77 -7
View File
@@ -29,13 +29,21 @@ export class Modal {
}
get isOpen() {
return this.el?.style.display === 'flex';
// While the exit animation runs, display is still 'flex' but the modal
// is non-interactive — treat it as closed.
return this.el?.style.display === 'flex' && !this.el?.classList.contains('closing');
}
open() {
this._previousFocus = document.activeElement;
// If a close animation is in flight, we still own the body lock — it
// gets released in finalize(). Skip the lockBody() call in that case
// so the lock count stays balanced.
const hadInFlightClose = this._exitCleanup !== null;
this.el!.classList.remove('closing');
this._cancelExitAnim();
this.el!.style.display = 'flex';
if (this._lock) lockBody();
if (this._lock && !hadInFlightClose) lockBody();
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
trapFocus(this.el!);
Modal._stack = Modal._stack.filter(m => m !== this);
@@ -51,18 +59,75 @@ export class Modal {
}
}
/** Pending exit-animation cleanup, so a re-open during the animation can cancel it. */
_exitCleanup: (() => void) | null = null;
_cancelExitAnim() {
if (this._exitCleanup) {
this._exitCleanup();
this._exitCleanup = null;
}
}
forceClose() {
releaseFocus(this.el!);
this.el!.style.display = 'none';
if (this._lock) unlockBody();
if (!this.el) return;
// Already closing → don't restart the animation.
if (this.el.classList.contains('closing')) return;
// Modal-state cleanup happens immediately so subsequent code that
// queries Modal._stack / focus sees a "closed" state. Body-lock
// release AND subclass cleanup (onForceClose) are deferred to
// finalize() — running them now would toggle html.modal-open or
// mutate DOM (widget destruction clears innerHTML) before the exit
// animation, causing a visible layout shift inside the modal.
releaseFocus(this.el);
this._initialValues = {};
this.hideError();
this.onForceClose();
Modal._stack = Modal._stack.filter(m => m !== this);
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
this._previousFocus = null;
}
// Run the exit animation, then hide. The animation is owned by the
// .modal-content child (the visually larger movement); we listen for
// its `animationend` and fall back to a timeout in case the user has
// prefers-reduced-motion or the element is detached mid-flight.
const el = this.el;
const content = el.querySelector('.modal-content') as HTMLElement | null;
const EXIT_MS = 220;
const finalize = () => {
this._exitCleanup = null;
// Guard against re-open: if open() was called during animation,
// the .closing class is gone and display is already 'flex' — the
// body lock is still held by the re-opened modal, so don't release.
if (!el.classList.contains('closing')) return;
el.classList.remove('closing');
el.style.display = 'none';
this.onForceClose();
if (this._lock) unlockBody();
};
let timer: number | null = null;
const onEnd = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
content?.removeEventListener('animationend', onEnd);
finalize();
};
this._exitCleanup = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
content?.removeEventListener('animationend', onEnd);
// Re-open during animation: still run subclass cleanup so the
// fresh open re-initializes from a clean slate (widgets etc.).
this.onForceClose();
};
el.classList.add('closing');
if (content) {
content.addEventListener('animationend', onEnd, { once: false });
}
timer = window.setTimeout(onEnd, EXIT_MS + 80);
}
async close() {
@@ -102,7 +167,12 @@ export class Modal {
if (this.errorEl) this.errorEl.style.display = 'none';
}
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
/**
* Hook for subclass cleanup on force-close (canvas, observers, widget
* .destroy() calls, etc.). Runs AFTER the exit animation completes (or
* when an in-flight close is canceled by reopen), so DOM mutations
* here do not cause a layout shift visible during the animation.
*/
onForceClose() {}
$(id: string) {
+29 -2
View File
@@ -142,14 +142,35 @@ export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v;
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state
export interface TutorialStepShape {
selector: string;
textKey: string;
position: string;
global?: boolean;
/** Optional sub-tab to switch to before highlighting. Lets the
* framework reveal panels that live behind tab switches. */
subTab?: string;
}
export interface TutorialState {
steps: { selector: string; textKey: string; position: string; global?: boolean }[];
steps: TutorialStepShape[];
overlay: HTMLElement;
mode: string;
step: number;
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null;
resolveTarget: (step: TutorialStepShape) => Element | null;
container: Element | null;
onClose: (() => void) | null;
/** Called with `step.subTab` before each step. Lets the tour
* reveal panels by switching the relevant sub-tab. */
switchSubTab: ((key: string) => void) | null;
/** CSS selector pointing at an element whose `textContent` is
* the current sub-tab label (e.g. `.tree-dd-trigger-title`).
* Rendered into the tooltip header as a breadcrumb so the user
* knows which panel is being highlighted. */
breadcrumbSelector: string | null;
/** Called with the current step before resolving the target.
* Lets a tour open/close auxiliary UI (e.g. side panels, popups)
* so steps inside that UI can be highlighted. */
prepare: ((step: TutorialStepShape) => void) | null;
}
export let activeTutorial: TutorialState | null = null;
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
@@ -269,6 +290,12 @@ export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
});
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
export const enginesCache = new DataCache<EngineInfo[]>({
endpoint: '/capture-engines',
extractData: json => json.engines || [],
});
enginesCache.subscribe(v => { availableEngines = v; });
export const audioSourcesCache = new DataCache<AudioSource[]>({
endpoint: '/audio-sources',
extractData: json => json.sources || [],
+139 -14
View File
@@ -16,14 +16,136 @@ export function desktopFocus(el: HTMLElement | null) {
if (el && !isTouchDevice()) el.focus();
}
export function toggleHint(btn: HTMLElement) {
const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null;
if (hint && hint.classList.contains('input-hint')) {
const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block';
btn.classList.toggle('active', !visible);
btn.setAttribute('aria-expanded', String(!visible));
/* Hint popover
The legacy implementation toggled the inline `<small class="input-hint">`
between display:none and display:block. That worked but pushed every
field below it down every help click reflowed half the modal. The
popover variant anchors a floating tooltip to the `?` button so the
form layout stays stable. The inline `<small>` is kept in the DOM
purely as a translation source: data-i18n still binds to it, and we
read its textContent at click time. */
let _hintPopoverEl: HTMLElement | null = null;
let _hintAnchorBtn: HTMLElement | null = null;
let _hintDismissBound = false;
function _ensureHintPopover(): HTMLElement {
if (_hintPopoverEl && document.body.contains(_hintPopoverEl)) return _hintPopoverEl;
const el = document.createElement('div');
el.className = 'hint-popover';
el.setAttribute('role', 'tooltip');
el.setAttribute('aria-hidden', 'true');
document.body.appendChild(el);
_hintPopoverEl = el;
return el;
}
function _closeHintPopover() {
if (_hintPopoverEl) {
_hintPopoverEl.classList.remove('open');
_hintPopoverEl.setAttribute('aria-hidden', 'true');
}
if (_hintAnchorBtn) {
_hintAnchorBtn.classList.remove('active');
_hintAnchorBtn.setAttribute('aria-expanded', 'false');
_hintAnchorBtn = null;
}
}
function _positionHintPopover(pop: HTMLElement, anchor: HTMLElement) {
// Park at viewport origin and measure natural size while still hidden.
pop.style.left = '0px';
pop.style.top = '0px';
const popRect = pop.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
const gap = 8;
const spaceBelow = window.innerHeight - anchorRect.bottom;
const placeAbove = spaceBelow < popRect.height + gap && anchorRect.top > popRect.height + gap;
const top = placeAbove
? anchorRect.top - popRect.height - gap
: anchorRect.bottom + gap;
let left = anchorRect.left + anchorRect.width / 2 - popRect.width / 2;
left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.dataset.placement = placeAbove ? 'top' : 'bottom';
const rawArrowX = anchorRect.left + anchorRect.width / 2 - left;
const arrowX = Math.max(14, Math.min(rawArrowX, popRect.width - 14));
pop.style.setProperty('--hint-arrow-x', `${arrowX}px`);
}
function _bindHintDismiss() {
if (_hintDismissBound) return;
_hintDismissBound = true;
document.addEventListener('mousedown', (e) => {
if (!_hintAnchorBtn) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (_hintAnchorBtn === target || _hintAnchorBtn.contains(target)) return;
if (_hintPopoverEl && _hintPopoverEl.contains(target)) return;
_closeHintPopover();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _hintAnchorBtn) {
e.stopPropagation();
_closeHintPopover();
}
});
// Reposition or close on layout shifts.
document.addEventListener('scroll', () => _closeHintPopover(), true);
window.addEventListener('resize', () => _closeHintPopover());
document.addEventListener('languageChanged', () => _closeHintPopover());
// Catch the case where a modal closes programmatically (Save button,
// success path) — the modal grows the .closing class which kicks off
// the fadeOut animation. Dismiss any anchored popover at the same time
// so we don't leave an orphaned tooltip floating over the page.
document.addEventListener('animationstart', (e) => {
if (!_hintAnchorBtn) return;
const target = e.target as HTMLElement | null;
if (target?.classList?.contains('modal') && target.classList.contains('closing')) {
_closeHintPopover();
}
}, true);
}
export function toggleHint(btn: HTMLElement) {
// Look for the .input-hint either as the immediate next sibling of the
// .label-row (form-group pattern) or anywhere else inside the
// surrounding .ds-toggle-text / .form-group wrapper. This keeps the
// helper working when the hint sits between the label-row and a
// status sub-line (e.g. OS Permission row in the settings modal).
const labelRow = btn.closest('.label-row') as HTMLElement | null;
let hint = labelRow?.nextElementSibling as HTMLElement | null;
if (!hint || !hint.classList.contains('input-hint')) {
const wrap = btn.closest('.ds-toggle-text, .form-group, .ds-toggle-row') as HTMLElement | null;
hint = wrap?.querySelector(':scope > .input-hint') as HTMLElement | null;
}
if (!hint || !hint.classList.contains('input-hint')) return;
// Force the legacy inline <small> to stay collapsed — the popover
// is now the sole visible surface for hints.
hint.style.display = 'none';
if (_hintAnchorBtn === btn) {
_closeHintPopover();
return;
}
const text = (hint.textContent || '').trim();
if (!text) return;
// Reuse a single popover so we don't pile up tooltip nodes.
_bindHintDismiss();
if (_hintAnchorBtn) _closeHintPopover();
const pop = _ensureHintPopover();
pop.textContent = text;
pop.setAttribute('aria-hidden', 'false');
_positionHintPopover(pop, btn);
pop.classList.add('open');
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
_hintAnchorBtn = btn;
}
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -433,12 +555,15 @@ export function formatCompact(n: number | null | undefined) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
}
export function formatUptime(seconds: number | null | undefined) {
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return t('time.hours_minutes', { h, m });
if (m > 0) return t('time.minutes_seconds', { m, s });
return t('time.seconds', { s });
const total = Math.floor(seconds);
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
}
@@ -475,12 +475,15 @@ function _renderLineList(): void {
function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props')!;
const sectionEl = document.getElementById('advcal-line-props-section');
const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none';
if (sectionEl) sectionEl.style.display = 'none';
return;
}
propsEl.style.display = '';
if (sectionEl) sectionEl.style.display = '';
const line = _state.lines[idx];
(document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
@@ -436,15 +436,19 @@ export function initAppearance(): void {
}
}
/** Render the Appearance tab content. Called when the tab is switched to. */
/** Render the Appearance tab content. Called when the tab is switched to.
* Each preset grid lives in its own channel-coded .ds-section so the panel
* matches the rest of the redesigned settings modal. The .ds-section-meta
* pill shows the active preset name in uppercase. */
export function renderAppearanceTab(): void {
const panel = document.getElementById('settings-panel-appearance');
if (!panel) return;
// Don't re-render if already populated
if (panel.querySelector('.appearance-presets')) {
// Don't re-render if already populated — just refresh selections + meta pills
if (panel.querySelector('.ds-section')) {
_updatePresetSelection('style', _activeStyleId);
_updatePresetSelection('bg', _activeBgEffectId);
_updateAppearanceMetaPills();
return;
}
@@ -476,18 +480,46 @@ export function renderAppearanceTab(): void {
}).join('');
panel.innerHTML = `
<div class="appearance-presets">
<div class="form-group">
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label>
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
<section class="ds-section" data-ch="magenta">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="appearance.style.label">${t('appearance.style.label')}</span>
<span class="ds-section-meta" id="appearance-style-meta"></span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<small class="input-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
<div class="ap-grid">${styleHtml}</div>
</div>
<div class="form-group" style="margin-top:1rem">
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label>
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
</section>
<section class="ds-section" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="appearance.bg.label">${t('appearance.bg.label')}</span>
<span class="ds-section-meta" id="appearance-bg-meta"></span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<small class="input-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
<div class="ap-grid">${bgHtml}</div>
</div>
</div>`;
</section>`;
_updateAppearanceMetaPills();
}
/** Refresh the .ds-section-meta pills to match the active preset names. */
function _updateAppearanceMetaPills(): void {
const styleMeta = document.getElementById('appearance-style-meta');
if (styleMeta) {
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
styleMeta.textContent = preset ? t(preset.nameKey).toUpperCase() : '';
}
const bgMeta = document.getElementById('appearance-bg-meta');
if (bgMeta) {
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
bgMeta.textContent = effect ? t(effect.nameKey).toUpperCase() : '';
}
}
/** Return the currently active style preset ID. */
@@ -549,12 +581,13 @@ function _ensureFont(url: string, id: string): void {
document.head.appendChild(link);
}
/** Update the visual selection ring on preset cards. */
/** Update the visual selection ring on preset cards and the meta pill. */
function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void {
const attr = type === 'style' ? 'style' : 'bg';
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
});
_updateAppearanceMetaPills();
}
// ─── Listen for theme changes to reapply preset colors ──────
+40 -29
View File
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts';
@@ -136,39 +137,49 @@ function getAssetTypeLabel(assetType: string): string {
// ── Card builder ──
export function createAssetCard(asset: Asset): string {
const icon = getAssetTypeIcon(asset.asset_type);
const sizeStr = formatFileSize(asset.size_bytes);
const prebuiltBadge = asset.prebuilt
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
: '';
const typeLabel = getAssetTypeLabel(asset.asset_type);
let playBtn = '';
if (asset.asset_type === 'sound') {
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
const badgeText = `ASSET · ${asset.asset_type.slice(0, 3).toUpperCase()}`;
const chips: ModChipOpts[] = [
{ icon: getAssetTypeIcon(asset.asset_type), text: typeLabel },
{ icon: _icon(P.fileText), text: sizeStr },
];
if (asset.prebuilt) {
chips.push({ icon: _icon(P.star), text: t('asset.prebuilt'), title: t('asset.prebuilt') });
}
return wrapCard({
dataAttr: 'data-id',
id: asset.id,
removeOnclick: `deleteAsset('${asset.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(asset.name)}">
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
${prebuiltBadge}
</div>
${renderTagChips(asset.tags)}`,
actions: `
${playBtn}
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
});
const iconActions: any[] = [];
if (asset.asset_type === 'sound') {
iconActions.push({ icon: ICON_PLAY_SOUND, onclick: '', title: t('asset.play'), dataAttrs: { 'data-action': 'play' } });
}
iconActions.push({ icon: ICON_DOWNLOAD, onclick: '', title: t('asset.download'), dataAttrs: { 'data-action': 'download' } });
iconActions.push({ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } });
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: asset.name,
metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
leds: ['on'],
menu: {
hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
deleteOnclick: `deleteAsset('${asset.id}')`,
},
},
body: {
chips,
},
foot: {
patchState: 'idle',
patchLabel: 'READY',
iconActions,
},
};
const cardHtml = wrapCard({ dataAttr: 'data-id', id: asset.id, mod });
const tagsHtml = renderTagChips(asset.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
// ── Sound playback ──
@@ -23,6 +23,7 @@ import { ICON_AUDIO_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { FilterListManager } from '../core/filter-list.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts } from '../core/mod-card.ts';
import { loadPictureSources } from './streams.ts';
// ── Module state ─────────────────────────────────────────────
@@ -266,34 +267,44 @@ export function renderAPTModalFilterList() { aptFilterManager.render(); }
// ── Card rendering (used by streams.ts) ───────────────────────
export function createAudioProcessingTemplateCard(tmpl: any): string {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map((fi: any) => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getAudioFilterName(fi.filter_id);
if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) {
const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
return wrapCard({
type: 'template-card',
dataAttr: 'data-apt-id',
id: tmpl.id,
removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAudioProcessingTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioProcessingTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL \u00b7 AUDIO PROC' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
leds: ['off'],
menu: {
duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`,
deleteOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'PIPELINE',
iconActions: [
{ icon: ICON_EDIT, onclick: `editAudioProcessingTemplate('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-apt-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
@@ -279,8 +279,15 @@ function _filterDevicesBySelectedTemplate() {
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
if (!select) return;
// Snapshot current selection BEFORE rebuilding options. We try to
// restore it afterwards by:
// 1. exact value match — `${index}:${loopback}` — survives a refresh
// whenever the OS keeps the same device index (the common case);
// 2. name match — survives an OS-side reindex (Windows occasionally
// reorders devices) provided the device label is unchanged.
const prevValue = select.value;
const prevOption = select.options[select.selectedIndex];
const prevName = prevOption ? prevOption.textContent : '';
const prevName = (prevOption?.textContent ?? '').trim();
const templateId = ((document.getElementById('audio-source-audio-template') as HTMLSelectElement | null) || { value: '' } as any).value;
const templates = _cachedAudioTemplates || [];
@@ -305,9 +312,16 @@ function _filterDevicesBySelectedTemplate() {
select.innerHTML = '<option value="-1:1">Default</option>';
}
if (prevName) {
const match = Array.from(select.options).find((o: HTMLOptionElement) => o.textContent === prevName);
if (match) select.value = match.value;
const opts = Array.from(select.options) as HTMLOptionElement[];
let restored: HTMLOptionElement | undefined;
if (prevValue) {
restored = opts.find(o => o.value === prevValue);
}
if (!restored && prevName) {
restored = opts.find(o => (o.textContent ?? '').trim() === prevName);
}
if (restored) {
select.value = restored.value;
}
if (_asDeviceEntitySelect) _asDeviceEntitySelect.destroy();
@@ -330,7 +344,14 @@ function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
if (!select) return;
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
const opt = Array.from(select.options).find((o: HTMLOptionElement) => o.value === val);
if (opt) select.value = val;
if (opt) {
select.value = val;
// EntitySelect's trigger button is a separate DOM node populated at
// construction time from the select's then-current value. Without
// this, the trigger keeps showing the first option even though the
// native select already points at the saved device.
if (_asDeviceEntitySelect) _asDeviceEntitySelect.setValue(val);
}
}
function _loadParentSources(selectedId?: any) {
@@ -11,9 +11,10 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -257,96 +258,180 @@ function renderAutomations(automations: any, sceneMap: any) {
}
}
type RulePillRenderer = (c: any) => string;
type RuleChipBuilder = (c: any) => ModChipOpts;
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = {
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
/* Build one chip per automation rule. The chip shows the rule type's
icon + a tight, scannable label. Mirrors the AUTO card in the
cards-redesign demo: rules read as a left-to-right chain leading into
the scene activation. */
const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
application: (c) => {
const apps = (c.apps || []).join(', ');
const apps = (c.apps || []).join(', ') || '—';
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`;
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
},
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
time_of_day: (c) => ({
icon: ICON_CLOCK,
text: `${c.start_time || '00:00'} ${c.end_time || '23:59'}`,
title: t('automations.rule.time_of_day'),
}),
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
},
display_state: (c) => {
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`;
return { icon: ICON_MONITOR, text: stateLabel, title: t('automations.rule.display_state') };
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
mqtt: (c) => ({
icon: ICON_RADIO,
text: `${c.topic || ''} = ${c.payload || '*'}`,
title: t('automations.rule.mqtt'),
}),
webhook: () => ({ icon: ICON_WEB, text: t('automations.rule.webhook') }),
home_assistant: (c) => ({
icon: _icon(P.home),
text: `${c.entity_id || ''} = ${c.state || '*'}`,
title: t('automations.rule.home_assistant'),
}),
};
/** Render a chain-arrow separator span. `+` between AND-rules,
* the localised OR label between OR-rules, and `` for the
* rule-chain scene-activation transition. */
function _chainArrow(glyph: string): string {
return `<span class="chain-arrow" aria-hidden="true">${escapeHtml(glyph)}</span>`;
}
/** Render a single chip as the same markup `renderModChips` produces,
* so the body chain row reads identically to chip arrays elsewhere
* (devices/value sources). Inline build so we can intersperse
* chain-arrow separators between chips. */
function _chipHtml(c: ModChipOpts): string {
const variant = c.variant === 'tag' ? ' chip--tag'
: c.variant === 'err' ? ' chip--err'
: '';
const link = c.onclick ? ' chip--link' : '';
const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : '';
const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : '';
return `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
}
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
// ── Rule chips: one per rule, joined by chain-arrow separators
// (`+` for AND, `OR` for OR — mirrors the demo's flow language). ──
const ruleChips: ModChipOpts[] = automation.rules.length
? automation.rules.map(c => {
const builder = RULE_CHIP_RENDERERS[c.rule_type];
return builder ? builder(c) : { text: c.rule_type };
})
: [{ text: t('automations.rules.empty') }];
let rulePills = '';
if (automation.rules.length === 0) {
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
} else {
const parts = automation.rules.map(c => {
const renderer = RULE_PILL_RENDERERS[c.rule_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
});
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
const logicGlyph = automation.rule_logic === 'and' ? '+' : 'OR';
const ruleChain = ruleChips.map(_chipHtml).join(_chainArrow(logicGlyph));
// Scene info
// ── Scene chip: the action — clickable, navigates to the scene card. ──
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
const sceneChipHtml = _chipHtml(scene ? {
icon: ICON_SCENE,
text: scene.name,
title: t('automations.scene'),
onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.scene_preset_id}')`,
variant: 'tag',
} : {
icon: ICON_SCENE,
text: t('automations.scene.none_selected'),
});
// Deactivation mode label
let deactivationMeta = '';
// ── Optional deactivation chip — `↩` revert or fallback scene.
// Rendered after the scene chip with a chain arrow so the card
// reads as: rules → scene ↩ (deactivation behaviour). ──
let deactivationHtml = '';
if (automation.deactivation_mode === 'revert') {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.revert')}</span>`;
deactivationHtml = _chainArrow('↩') + _chipHtml({
icon: ICON_UNDO,
text: t('automations.deactivation_mode.revert'),
});
} else if (automation.deactivation_mode === 'fallback_scene') {
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
if (fallback) {
const fbColor = fallback.color || '#4fc3f7';
deactivationMeta = `<span class="card-meta stream-card-link" onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.deactivation_scene_preset_id}')">${ICON_UNDO} <span style="color:${fbColor}">&#x25CF;</span> ${escapeHtml(fallback.name)}</span>`;
} else {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}</span>`;
}
deactivationHtml = _chainArrow('↩') + _chipHtml(fallback ? {
icon: ICON_UNDO,
text: fallback.name,
title: t('automations.deactivation_mode.fallback_scene'),
onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.deactivation_scene_preset_id}')`,
} : {
icon: ICON_UNDO,
text: t('automations.deactivation_mode.fallback_scene'),
});
}
let lastActivityMeta = '';
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('')}${sceneChipHtml}${deactivationHtml}</div>`;
// ── State surfaces: LED + patch indicator ──
// Active = blink (live signal); Enabled-but-idle = off (waiting);
// Disabled = fault (red, indicates unavailable rather than error).
const ledState = !automation.enabled ? 'fault'
: automation.is_active ? 'blink'
: 'off';
const patchState = !automation.enabled ? 'offline'
: automation.is_active ? 'live'
: 'idle';
const patchLabel = !automation.enabled ? t('automations.status.disabled').toUpperCase()
: automation.is_active ? t('automations.status.active').toUpperCase()
: t('automations.status.inactive').toUpperCase();
// ── Meta: last-fired timestamp only — the rule/scene chain is
// already laid out below, so meta stays a quiet single-line
// history hint. ──
let metaHtml: string | undefined;
if (automation.last_activated_at) {
const ts = new Date(automation.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
metaHtml = `${t('automations.last_activated')} · ${escapeHtml(ts.toLocaleString())}`;
}
return wrapCard({
// ── Badge: "AUTO · XX" — short id slice mirrors the demo's
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA';
const mod: ModCardOpts = {
running: automation.is_active,
head: {
badge: { text: `AUTO · ${shortId}` },
name: automation.name,
metaHtml,
leds: [ledState],
menu: {
duplicateOnclick: `cloneAutomation('${automation.id}')`,
hideOnclick: `toggleCardHidden('automations','${automation.id}')`,
deleteOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
},
},
body: {
extraHtml: chipsHtml,
},
foot: {
patchState,
patchLabel,
secondaryActions: [
automation.enabled
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
: { label: t('search.action.enable'), icon: ICON_START, onclick: `toggleAutomationEnabled('${automation.id}', true)`, variant: 'go' },
],
iconActions: [
{ icon: ICON_EDIT, onclick: `openAutomationEditor('${automation.id}')`, title: t('automations.edit') },
],
},
};
const cardHtml = wrapCard({
dataAttr: 'data-automation-id',
id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(automation.name)}">
<span class="card-title-text">${escapeHtml(automation.name)}</span>
<span class="badge badge-automation-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${rulePills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
${renderTagChips(automation.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START}
</button>`,
mod,
});
const tagsHtml = renderTagChips(automation.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
export async function openAutomationEditor(automationId?: any, cloneData?: any) {
@@ -55,6 +55,8 @@ class CalibrationModal extends Modal {
(document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none';
} else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId);
@@ -162,6 +164,8 @@ export async function showCalibration(deviceId: any) {
(document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id;
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count;
(document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none';
const testSectionDevMode = document.getElementById('calibration-test-setup-section');
if (testSectionDevMode) testSectionDevMode.style.display = 'none';
(document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none';
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
@@ -262,6 +266,8 @@ export async function showCSSCalibration(cssId: any) {
_calTestDeviceList = devices;
const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
testGroup.style.display = devices.length ? '' : 'none';
const testSection = document.getElementById('calibration-test-setup-section') as HTMLElement | null;
if (testSection) testSection.style.display = '';
// Pre-select device: 1) LED count match, 2) last remembered, 3) first
if (devices.length) {
@@ -18,6 +18,7 @@ import {
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE,
} from '../../core/icons.ts';
import { wrapCard } from '../../core/card-colors.ts';
import type { ModCardOpts } from '../../core/mod-card.ts';
import type { ColorStripSource } from '../../types.ts';
import { bindableValue, bindableColor } from '../../types.ts';
import { renderTagChips } from '../../core/tag-input.ts';
@@ -46,7 +47,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
/* ── Non-picture types set ────────────────────────────────────── */
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'static', 'gradient', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'math_wave',
]);
@@ -64,16 +65,6 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${clockBadge}
`;
},
color_cycle: (source, { clockBadge }) => {
const colors = source.colors || [];
const swatches = colors.slice(0, 8).map((c: any) =>
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
).join('');
return `
<span class="stream-card-prop">${swatches}</span>
${clockBadge}
`;
},
gradient: (source, { clockBadge, animBadge }) => {
const stops = source.stops || [];
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
@@ -273,6 +264,24 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
/* ── Main card builder ────────────────────────────────────────── */
const STRIP_BADGE: Record<string, string> = {
static: 'STRIP · COLOR',
gradient: 'STRIP · GRD',
effect: 'STRIP · FX',
composite: 'STRIP · COMP',
mapped: 'STRIP · MAP',
audio: 'STRIP · AUDIO',
api_input: 'STRIP · API',
notification: 'STRIP · NOTIF',
daylight: 'STRIP · DAY',
candlelight: 'STRIP · CANDLE',
weather: 'STRIP · WEATHER',
key_colors: 'STRIP · KEY',
math_wave: 'STRIP · WAVE',
processed: 'STRIP · OUT',
picture_advanced: 'STRIP · CALIB',
};
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
// Clock crosslink badge
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
@@ -291,45 +300,54 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
: _renderPictureCardProps(source, pictureSourceMap);
const icon = getColorStripIcon(source.source_type);
const ledCount = (source as any).led_count || 0;
const badgeText = STRIP_BADGE[source.source_type] || 'STRIP · MAPPED';
const metaText = ledCount ? `${ledCount} px` : (source.source_type || '').replace(/_/g, ' ');
const isNotification = source.source_type === 'notification';
const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
const calibrationBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
const overlayBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
: '';
const testNotifyBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
: '';
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const isKeyColors = source.source_type === 'key_colors';
const regionsBtn = isKeyColors
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</button>`
: '';
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
return wrapCard({
dataAttr: 'data-css-id',
id: source.id,
removeOnclick: `deleteColorStrip('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(source.name)}">
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span>
</div>
</div>
<div class="stream-card-props">
${propsHtml}
</div>
${renderTagChips(source.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
});
const iconActions: any[] = [];
iconActions.push({ icon: ICON_TEST, onclick: `event.stopPropagation(); testColorStrip('${source.id}')`, title: t('color_strip.test.title') });
if (isPictureKind) {
const calibrationOnclick = source.source_type === 'picture_advanced'
? `showAdvancedCalibration('${source.id}')`
: `showCSSCalibration('${source.id}')`;
iconActions.push({ icon: ICON_CALIBRATION, onclick: calibrationOnclick, title: t('calibration.title') });
iconActions.push({ icon: ICON_OVERLAY, onclick: `event.stopPropagation(); toggleCSSOverlay('${source.id}')`, title: t('overlay.toggle') });
}
if (isKeyColors) {
iconActions.push({ icon: ICON_PATTERN_TEMPLATE, onclick: `event.stopPropagation(); configureKCRegions('${source.id}')`, title: t('color_strip.key_colors.configure_regions') });
}
if (isNotification) {
iconActions.push({ icon: ICON_BELL, onclick: `event.stopPropagation(); testNotification('${source.id}')`, title: t('color_strip.notification.test') });
iconActions.push({ icon: ICON_AUTOMATION, onclick: `event.stopPropagation(); showNotificationHistory()`, title: t('color_strip.notification.history.title') });
}
iconActions.push({ icon: ICON_EDIT, onclick: `showCSSEditor('${source.id}')`, title: t('common.edit') });
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: source.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
menu: {
duplicateOnclick: `cloneColorStrip('${source.id}')`,
hideOnclick: `toggleCardHidden('color-strips','${source.id}')`,
deleteOnclick: `deleteColorStrip('${source.id}')`,
},
},
body: {
extraHtml: propsHtml ? `<div class="stream-card-props">${propsHtml}</div>` : undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'STRIP',
iconActions,
},
};
const cardHtml = wrapCard({ dataAttr: 'data-css-id', id: source.id, mod });
const tagsHtml = renderTagChips(source.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
@@ -1,68 +0,0 @@
/**
* Color Strip Sources Color Cycle helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { ICON_TRASH } from '../../core/icons.ts';
import { rgbArrayToHex, hexToRgbArray } from '../css-gradient-editor.ts';
/* ── State ────────────────────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
/* ── DOM sync ─────────────────────────────────────────────────── */
function _syncColorCycleFromDom() {
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
if (inputs.length > 0) {
_colorCycleColors = Array.from(inputs).map(el => el.value);
}
}
/* ── Rendering ────────────────────────────────────────────────── */
function _colorCycleRenderList() {
const list = document.getElementById('color-cycle-colors-list') as HTMLElement | null;
if (!list) return;
const canRemove = _colorCycleColors.length > 2;
list.innerHTML = _colorCycleColors.map((hex, i) => `
<div class="color-cycle-item">
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
: `<div style="height:14px"></div>`}
</div>
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
}
/* ── Public actions ───────────────────────────────────────────── */
export function colorCycleAddColor() {
_syncColorCycleFromDom();
_colorCycleColors.push('#ffffff');
_colorCycleRenderList();
}
export function colorCycleRemoveColor(i: number) {
_syncColorCycleFromDom();
if (_colorCycleColors.length <= 2) return;
_colorCycleColors.splice(i, 1);
_colorCycleRenderList();
}
export function colorCycleGetColors() {
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
return Array.from(inputs).map(el => hexToRgbArray(el.value));
}
/* ── Load / Reset ─────────────────────────────────────────────── */
export function loadColorCycleState(css: any) {
const raw = css && css.colors;
_colorCycleColors = (raw && raw.length >= 2)
? raw.map((c: any) => rgbArrayToHex(c))
: [..._DEFAULT_CYCLE_COLORS];
_colorCycleRenderList();
}
@@ -1,6 +1,6 @@
/**
* Color Strip Sources CRUD, editor orchestration, type switching.
* Sub-modules handle type-specific logic: audio, cards, color-cycle, composite,
* Sub-modules handle type-specific logic: audio, cards, composite,
* game-event, gradient, mapped, math-wave, notification, test.
*/
@@ -62,9 +62,6 @@ import {
mappedSetAvailableSources, mappedRenderList, mappedAddZone, mappedRemoveZone,
mappedGetZones, loadMappedState, resetMappedState, getMappedZones,
} from './mapped.ts';
import {
colorCycleAddColor, colorCycleRemoveColor, colorCycleGetColors, loadColorCycleState,
} from './color-cycle.ts';
import {
destroyAudioWidgets, ensureAudioSensitivityWidget, ensureAudioSmoothingWidget,
ensureAudioBeatDecayWidget, ensureAudioColorWidget, ensureAudioColorPeakWidget,
@@ -90,11 +87,10 @@ export {
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
};
export { _getAnimationPayload, colorCycleGetColors as _colorCycleGetColors };
export { _getAnimationPayload };
export { addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange };
export { mathWaveAddLayer, mathWaveRemoveLayer };
export { mappedAddZone, mappedRemoveZone };
export { colorCycleAddColor, colorCycleRemoveColor };
export { createColorStripCard };
export {
onGradientPresetChange, promptAndSaveGradientPreset, deleteAndRefreshGradientPreset,
@@ -158,7 +154,6 @@ class CSSEditorModal extends Modal {
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
cycle_colors: JSON.stringify(colorCycleGetColors()),
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
@@ -190,6 +185,7 @@ class CSSEditorModal extends Modal {
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
daylight_longitude: (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value,
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
@@ -307,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
// ══════════════════════════════════════════════════════════════════
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'picture', 'picture_advanced', 'static', 'gradient',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event', 'math_wave',
@@ -346,7 +342,6 @@ const CSS_SECTION_MAP: Record<string, string> = {
'picture': 'css-editor-picture-section',
'picture_advanced': 'css-editor-picture-section',
'static': 'css-editor-static-section',
'color_cycle': 'css-editor-color-cycle-section',
'gradient': 'css-editor-gradient-section',
'effect': 'css-editor-effect-section',
'composite': 'css-editor-composite-section',
@@ -422,7 +417,7 @@ export function onCSSTypeChange() {
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
hasLedCount.includes(type) ? '' : 'none';
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -1006,15 +1001,6 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
},
},
color_cycle: {
load(css) { loadColorCycleState(css); },
reset() { loadColorCycleState(null); },
getPayload(name) {
const cycleColors = colorCycleGetColors();
if (cycleColors.length < 2) { cssEditorModal.showError(t('color_strip.color_cycle.min_colors')); return null; }
return { name, colors: cycleColors };
},
},
gradient: {
load(css) {
const gradientId = css.gradient_id || '';
@@ -1180,6 +1166,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
_syncDaylightSpeedVisibility();
},
getPayload(name) {
return {
@@ -1563,7 +1550,7 @@ export async function saveCSSEditor() {
payload.source_type = knownType ? sourceType : 'picture';
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
}
@@ -15,14 +15,14 @@ import {
} from '../../core/icons.ts';
import { EntitySelect } from '../../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts';
import { testNotification, _getAnimationPayload } from './index.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
import { openAuthedWs } from '../../core/ws-auth.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification',
]);
function _collectPreviewConfig() {
@@ -35,10 +35,6 @@ function _collectPreviewConfig() {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
@@ -75,11 +75,17 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
patches: 'dashboard.perf.active_patches',
fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps',
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
errors: 'dashboard.perf.errors',
devices: 'dashboard.perf.devices',
cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram',
gpu: 'dashboard.perf.gpu',
temp: 'dashboard.perf.temp',
network: 'dashboard.perf.network',
device_latency: 'dashboard.perf.device_latency',
send_timing: 'dashboard.perf.send_timing',
};
let _unsubscribe: (() => void) | null = null;
@@ -129,6 +135,13 @@ function _mountPanel(): void {
`;
document.body.appendChild(panel);
// Force a layout flush so the initial off-screen transform commits before
// the caller adds `.is-open`. Without this, on the first open after a page
// reload the browser collapses both styles into one paint and the slide-in
// transition is skipped.
void panel.offsetWidth;
void backdrop.offsetWidth;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('is-open')) {
closeDashboardCustomize();
@@ -35,13 +35,18 @@ export type SectionKey =
export type PerfCellKey =
| 'patches'
| 'fps'
| 'capture_fps'
| 'capture_fps_actual'
| 'errors'
| 'devices'
| 'cpu'
| 'ram'
| 'gpu'
| 'temp'
// Reserved.
| 'network'
| 'device_latency'
| 'send_timing'
// Reserved.
| 'disk'
| 'audio-peak';
@@ -142,11 +147,17 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
perfCells: [
_defaultPerfCell('patches'),
_defaultPerfCell('fps'),
_defaultPerfCell('capture_fps'),
_defaultPerfCell('capture_fps_actual', false),
_defaultPerfCell('errors'),
_defaultPerfCell('devices'),
_defaultPerfCell('cpu'),
_defaultPerfCell('ram'),
_defaultPerfCell('gpu'),
_defaultPerfCell('temp', false),
_defaultPerfCell('network', false),
_defaultPerfCell('device_latency', false),
_defaultPerfCell('send_timing', false),
],
global: {
width: 'full',
@@ -218,6 +229,114 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
};
}
/** Structural deep-equal that ignores `undefined` properties and key order.
* Needed because a saved layout coming back from JSON.parse may omit
* optional fields (e.g. `colorOverride`) that the factory output also
* omits but a naive `JSON.stringify` comparison can still differ if
* insertion order ever drifts. */
function _deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!_deepEqual(a[i], b[i])) return false;
}
return true;
}
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao).filter(k => ao[k] !== undefined);
const bKeys = Object.keys(bo).filter(k => bo[k] !== undefined);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!_deepEqual(ao[k], bo[k])) return false;
}
return true;
}
/** Detect which preset (if any) the layout currently matches. Recomputed
* on every save/load so the "modified" hint is the truth, not a flag that
* drifts when a user edits then reverts a setting.
*
* Compares only the data-bearing fields `presetActive` itself is ignored,
* since it's the value we're computing. */
function _computeActivePreset(layout: DashboardLayoutV1): string | undefined {
for (const [name, factory] of Object.entries(PRESETS)) {
const p = factory();
if (
layout.version === p.version &&
_deepEqual(layout.global, p.global) &&
_deepEqual(layout.sections, p.sections) &&
_deepEqual(layout.perfCells, p.perfCells)
) {
return name;
}
}
return undefined;
}
/** Diagnostic: list per-preset mismatches for the current layout. Exposed
* on `window.__dashboardLayoutDiff` so a user reporting "MODIFIED with no
* changes" can run it from DevTools and see exactly which field drifted. */
function _diffAgainstPresets(layout: DashboardLayoutV1): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const [name, factory] of Object.entries(PRESETS)) {
const p = factory();
const diffs: string[] = [];
if (layout.version !== p.version) diffs.push(`version: ${layout.version} vs ${p.version}`);
const asRec = (o: object): Record<string, unknown> => o as unknown as Record<string, unknown>;
if (!_deepEqual(layout.global, p.global)) {
for (const k of Object.keys({ ...layout.global, ...p.global })) {
const lv = asRec(layout.global)[k];
const pv = asRec(p.global)[k];
if (!_deepEqual(lv, pv)) diffs.push(`global.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
if (!_deepEqual(layout.sections, p.sections)) {
const lKeys = layout.sections.map(s => s.key).join(',');
const pKeys = p.sections.map(s => s.key).join(',');
if (lKeys !== pKeys) diffs.push(`sections.order: [${lKeys}] vs [${pKeys}]`);
for (const ls of layout.sections) {
const ps = p.sections.find(x => x.key === ls.key);
if (!ps) { diffs.push(`sections.${ls.key}: extra`); continue; }
if (!_deepEqual(ls, ps)) {
for (const k of Object.keys({ ...ls, ...ps })) {
const lv = asRec(ls)[k];
const pv = asRec(ps)[k];
if (!_deepEqual(lv, pv)) diffs.push(`sections.${ls.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
}
}
if (!_deepEqual(layout.perfCells, p.perfCells)) {
const lKeys = layout.perfCells.map(c => c.key).join(',');
const pKeys = p.perfCells.map(c => c.key).join(',');
if (lKeys !== pKeys) diffs.push(`perfCells.order: [${lKeys}] vs [${pKeys}]`);
for (const lc of layout.perfCells) {
const pc = p.perfCells.find(x => x.key === lc.key);
if (!pc) { diffs.push(`perfCells.${lc.key}: extra`); continue; }
if (!_deepEqual(lc, pc)) {
for (const k of Object.keys({ ...lc, ...pc })) {
const lv = asRec(lc)[k];
const pv = asRec(pc)[k];
if (!_deepEqual(lv, pv)) diffs.push(`perfCells.${lc.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
}
}
out[name] = diffs;
}
return out;
}
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__dashboardLayoutDiff = () => _diffAgainstPresets(_current);
}
let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio');
let _serverSyncedOnce = false;
const _listeners = new Set<() => void>();
@@ -289,7 +408,7 @@ export async function syncDashboardLayoutFromServer(): Promise<void> {
/** Persist a layout. Updates in-memory state immediately, debounces
* the network write, and notifies listeners synchronously. */
export function saveDashboardLayout(next: DashboardLayoutV1): void {
_current = _clone(next, next.presetActive);
_current = _clone(next, _computeActivePreset(next));
try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
_notify();
if (_saveTimer) clearTimeout(_saveTimer);
@@ -540,7 +659,7 @@ function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
base.global = { ...base.global, ...obj.global };
}
if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive;
base.presetActive = _computeActivePreset(base);
return base;
}
@@ -575,5 +694,6 @@ function _migrateFromLegacyKeys(): DashboardLayoutV1 {
} catch { /* ignore */ }
}
layout.presetActive = _computeActivePreset(layout);
return layout;
}
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices, rerenderPerfGrid } from './perf-charts.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import {
@@ -39,6 +39,11 @@ let _fpsCurrentHistory: Record<string, number[]> = {};
let _fpsCharts: Record<string, any> = {};
let _lastRunningIds: string[] = [];
let _lastSyncClockIds: string = '';
/** Previous cumulative `bytes_sent` summed across running targets.
* Used to convert the WLED transport byte counter into a per-poll
* delta that drives the Network throughput sparkline. `null` until
* the first poll so we don't emit a phantom spike on page load. */
let _prevTotalBytesSent: number | null = null;
let _uptimeBase: Record<string, UptimeBase> = {};
let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
let _uptimeElements: Record<string, Element> = {};
@@ -99,10 +104,6 @@ function _startUptimeTimer(): void {
if (!el) continue;
const seconds = _getInterpolatedUptime(id);
if (seconds != null) {
// Pure text — the .mod-metric "UPTIME" label already
// carries the icon meaning, and dropping it gives the
// value enough room for "4m 32s" / "1h 17m" without
// clipping inside the fixed-width metric cell.
el.textContent = formatUptime(seconds);
}
}
@@ -322,9 +323,11 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
? `${t('ha_source.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const host = (conn.host || '').trim();
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
@@ -402,9 +405,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
}
const meta = card.querySelector('.mod-meta');
if (meta) {
const host = (conn.host || '').trim();
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
@@ -601,6 +606,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[];
updateDevices(deviceStateList);
// Device-latency cell — avg / max ping latency across
// online devices. Already deduplicated by device_id
// (this list is keyed on device, not target). Offline
// devices contribute to the total count but not the
// latency aggregate.
let onlineCount = 0;
let latencyMax = 0;
let latencySum = 0;
let latencyN = 0;
for (const ds of deviceStateList) {
if (ds?.device_online) onlineCount++;
const l = ds?.device_latency_ms;
if (typeof l === 'number' && Number.isFinite(l) && l >= 0) {
latencySum += l;
latencyN += 1;
if (l > latencyMax) latencyMax = l;
}
}
const latencyAvg = latencyN > 0 ? latencySum / latencyN : null;
updateDeviceLatency(
latencyAvg,
latencyN > 0 ? latencyMax : null,
onlineCount,
deviceStateList.length,
);
} catch { /* ignore parse errors */ }
}
@@ -652,8 +683,17 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
// a dashed reference line ("max achievable throughput").
const fpsValues: number[] = [];
const captureFpsValues: number[] = [];
let fpsSum = 0;
let fpsTargetSum = 0;
let captureFpsSum = 0;
// Capture-actual aggregates: only count targets whose stream
// reports a measured rate (capture-backed, e.g. screen capture).
// Synthetic streams report null and are excluded so the cell
// can read "no captures" instead of "0 fps".
let captureFpsActualSum = 0;
let captureActualReportingCount = 0;
let captureFpsActualTargetSum = 0;
for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
@@ -666,10 +706,75 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
?? (r.settings || {}).fps
?? r.update_rate;
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
const captureFps = r.state?.fps_capture;
if (typeof captureFps === 'number' && captureFps > 0) {
captureFpsValues.push(captureFps);
captureFpsSum += captureFps;
}
const captureFpsActual = r.state?.fps_capture_actual;
if (typeof captureFpsActual === 'number') {
captureFpsActualSum += captureFpsActual;
captureActualReportingCount += 1;
// Use this target's source-side rate as the per-capture
// ceiling so the "% of requested" subtitle matches the
// captures actually being measured.
if (typeof captureFps === 'number' && captureFps > 0) {
captureFpsActualTargetSum += captureFps;
}
}
}
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null;
const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount);
// Errors / dropped frames — fed cumulative totals; the perf
// cell turns them into per-second rates by tracking deltas
// across polls. Computed across running targets only so a
// long-stopped target's historical errors don't keep the
// counter elevated forever.
let totalErrors = 0;
let totalSkipped = 0;
let totalBytesSent = 0;
let sendTimingSum = 0;
let sendTimingMax = 0;
let sendTimingCount = 0;
for (const r of running) {
const e = r.metrics?.errors_count;
if (typeof e === 'number' && e > 0) totalErrors += e;
const s = r.state?.frames_skipped;
if (typeof s === 'number' && s > 0) totalSkipped += s;
const b = r.state?.bytes_sent;
if (typeof b === 'number' && b > 0) totalBytesSent += b;
const t = r.state?.timing_send_ms;
if (typeof t === 'number' && Number.isFinite(t) && t >= 0) {
sendTimingSum += t;
sendTimingCount += 1;
if (t > sendTimingMax) sendTimingMax = t;
}
}
updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval);
// Network throughput — convert cumulative byte counter into
// a per-second rate via deltas, same shape as the errors
// cell. Counter resets (target stop/restart) leave the
// total unchanged or smaller; rate is clamped non-negative
// inside the perf-charts module.
const pollSec = Math.max(0.05, dashboardPollInterval / 1000);
const bytesDelta = _prevTotalBytesSent != null
? Math.max(0, totalBytesSent - _prevTotalBytesSent)
: 0;
const bytesPerSec = _prevTotalBytesSent != null ? bytesDelta / pollSec : 0;
_prevTotalBytesSent = totalBytesSent;
updateNetworkThroughput(bytesPerSec, totalBytesSent);
// Send-timing — already an instantaneous "ms last frame"
// value, so we just average / max across running targets.
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
+198 -52
View File
@@ -12,8 +12,9 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
@@ -22,6 +23,30 @@ import type { Device } from '../types.ts';
let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null;
/* The General Settings modal groups its many conditional fields into
four `.ds-section` panels (Identity / Connection / Hardware / Behavior).
showSettings() toggles individual `.form-group` visibility by device
type and capability this helper then collapses any section whose
form-groups have all ended up `display: none`, so the user never
sees a section header with nothing underneath it. */
function _updateSettingsSectionVisibility() {
const root = document.getElementById('device-settings-modal');
if (!root) return;
const sections = root.querySelectorAll<HTMLElement>('.ds-section');
sections.forEach((sec) => {
if (sec.dataset.dsKey === 'identity') {
sec.dataset.dsEmpty = 'false';
return;
}
const groups = sec.querySelectorAll<HTMLElement>('.form-group');
let anyVisible = false;
groups.forEach((g) => {
if (g.style.display !== 'none') anyVisible = true;
});
sec.dataset.dsEmpty = anyVisible ? 'false' : 'true';
});
}
function _ensureSettingsCsptSelect() {
const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (!sel) return;
@@ -127,76 +152,180 @@ export function createDeviceCard(device: Device & { state?: any }) {
const devVersion = state.device_version;
const devLastChecked = state.device_last_checked;
let healthClass, healthTitle, healthLabel;
// Health dot — kept inline beside the name so the existing
// event-driven status updates (.health-dot[data-device-id=…])
// continue to work without needing new selectors.
let healthClass: string, healthTitle: string;
if (devLastChecked === null || devLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (devOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
healthTitle = t('device.health.online');
if (devName) healthTitle += ` - ${devName}`;
if (devVersion) healthTitle += ` v${devVersion}`;
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
healthLabel = '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.device_error) healthTitle += `: ${state.device_error}`;
healthLabel = '';
}
const healthDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status" aria-label="${escapeHtml(healthTitle)}"></span>`;
// ── LED bezel — 1-3 dots reflecting connection state ──────────
let leds: LedState[];
if (devLastChecked === null || devLastChecked === undefined) {
leds = ['off'];
} else if (devOnline) {
leds = ['on', 'blink', 'blink'];
} else {
leds = ['fault'];
}
const ledCount = state.device_led_count || device.led_count;
// ── Type label for the badge ──
const badgeText = `${(device.device_type || 'wled').toUpperCase()} · OUT`;
// Parse zone names from OpenRGB URL for badge display
// ── Metadata sub-line — URL becomes a clickable anchor when http(s),
// plain text otherwise. Built as HTML so the link is real, not a
// chip. Each part is HTML-escaped individually. ──
const metaPartsHtml: string[] = [];
if (device.url && device.url.startsWith('http')) {
const safeUrl = escapeHtml(device.url);
const display = escapeHtml(device.url.replace(/^https?:\/\//, ''));
metaPartsHtml.push(
`<a class="mod-meta__link" href="${safeUrl}" target="_blank" rel="noopener" title="${t('device.button.webui') || 'Open device web UI'}" onclick="event.stopPropagation();">${display} ${ICON_WEB}</a>`
);
} else if (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://')) {
metaPartsHtml.push(escapeHtml(device.url));
}
if (devVersion) metaPartsHtml.push(escapeHtml(`v${devVersion}`));
// ── Metric strip — three blocks: LED count, latency, and chip type
// (which carries both the LED chip identifier and RGB/RGBW colour
// channel layout, stacked in two rows). ──
const ledCount = state.device_led_count || device.led_count;
const metrics: ModMetricOpts[] = [];
if (ledCount) {
metrics.push({ k: t('device.led_count') || 'PIXELS', v: String(ledCount), title: t('device.led_count') });
}
if (devOnline && devLatency !== null && devLatency !== undefined) {
metrics.push({ k: 'LAT', v: `${Math.round(devLatency)}<small>ms</small>`, accent: true });
}
const ledTypeName = state.device_led_type
? state.device_led_type.replace(/ RGBW$/, '')
: null;
const colorChannels = state.device_rgbw === true ? 'RGBW'
: state.device_rgbw === false ? 'RGB'
: null;
if (ledTypeName || colorChannels) {
const primary = ledTypeName ?? colorChannels!;
const secondary = ledTypeName && colorChannels ? colorChannels : null;
const subHtml = secondary
? `<span class="v-sub">${escapeHtml(secondary)}</span>`
: '';
metrics.push({
k: t('dashboard.device.chip') || 'CHIP',
v: `${escapeHtml(primary)}${subHtml}`,
variant: 'text-stack',
title: [state.device_led_type, colorChannels].filter(Boolean).join(' · ') || undefined,
});
}
// ── Chips: OpenRGB zones (LED type/RGBW now live in the metric strip) ──
const openrgbZones = isOpenrgbDevice(device.device_type)
? _splitOpenrgbZone(device.url).zones : [];
const chips: ModChipOpts[] = [];
if (openrgbZones.length) {
for (const z of openrgbZones) {
chips.push({ icon: ICON_LED, text: String(z), title: t('device.openrgb.zone') });
}
}
return wrapCard({
// ── Patch indicator state ──
const isOnline = devOnline && devLastChecked != null;
const patchState: 'live' | 'standby' | 'offline' | 'idle' =
devLastChecked == null ? 'idle' :
isOnline ? 'live' :
'offline';
const patchLabel = devLastChecked == null ? (t('device.health.checking') || 'CHECKING').toUpperCase()
: isOnline ? (t('device.health.online') || 'ONLINE').toUpperCase()
: (t('device.health.offline') || 'OFFLINE').toUpperCase();
// ── Brightness fader ──
const hasBrightness = (device.capabilities || []).includes('brightness_control');
const brightnessVal = _deviceBrightnessCache[device.id] ?? 0;
const brightnessLoaded = _deviceBrightnessCache[device.id] != null;
// ── Foot actions ──
const hasPower = (device.capabilities || []).includes('power_control');
const iconActions: ModBtnOpts[] = [];
if (hasPower && isOnline) {
// Power-off as a stop-style icon button (was in topButtons before)
iconActions.push({
icon: ICON_STOP_PLAIN,
onclick: `turnOffDevice('${device.id}')`,
title: t('device.button.power_off'),
variant: 'stop',
});
}
iconActions.push({
icon: ICON_REFRESH,
onclick: `event.stopPropagation(); pingDevice('${device.id}')`,
title: t('device.button.ping'),
});
iconActions.push({
icon: ICON_SETTINGS,
onclick: `showSettings('${device.id}')`,
title: t('device.button.settings'),
});
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: device.name || device.id,
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
healthDot,
leds,
menu: {
duplicateOnclick: `cloneDevice('${device.id}')`,
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
deleteOnclick: `removeDevice('${device.id}')`,
},
},
body: {
metrics: metrics.length ? metrics : undefined,
chips: chips.length ? chips : undefined,
fader: hasBrightness ? {
label: t('device.brightness') || 'Bright',
value: brightnessVal,
max: 255,
sliderId: undefined,
oninput: `updateBrightnessLabel('${device.id}', this.value)`,
onchange: `saveCardBrightness('${device.id}', this.value)`,
dataAttrs: { 'data-device-brightness': device.id },
disabled: !brightnessLoaded,
} : undefined,
},
foot: {
patchState,
patchLabel,
iconActions,
},
running: isOnline && hasBrightness && brightnessVal > 0,
};
// Tag chips render via existing renderTagChips() — append after wrapCard
// returns, since the modular API doesn't yet have a "tags" slot.
const cardHtml = wrapCard({
dataAttr: 'data-device-id',
id: device.id,
topButtons: (device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : '',
removeOnclick: `removeDevice('${device.id}')`,
removeTitle: t('device.button.remove'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span>
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${openrgbZones.length
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" data-last-seen="${device.id}"></span></div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>` : ''}
${renderTagChips(device.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
${ICON_REFRESH}
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
</button>`,
classes: hasBrightness && !brightnessLoaded ? 'brightness-loading' : '',
mod,
});
const tags = renderTagChips(device.tags);
if (!tags) return cardHtml;
// Insert tags before the closing </div> of the wrapper card.
return cardHtml.replace(/<\/div>\s*$/, `${tags}</div>`);
}
export async function turnOffDevice(deviceId: any) {
@@ -519,6 +648,7 @@ export async function showSettings(deviceId: any) {
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
_updateSettingsSectionVisibility();
settingsModal.snapshot();
settingsModal.open();
@@ -614,7 +744,17 @@ export async function saveDeviceSettings() {
// Brightness
export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
if (!slider) return;
const v = parseInt(value);
slider.title = Math.round(v / 255 * 100) + '%';
// mod-card fader visuals — fill width + numeric readout
const fader = slider.closest('.mod-fader');
if (fader) {
const fill = fader.querySelector('.mod-fader__fill') as HTMLElement | null;
if (fill) fill.style.width = `${(v / 255) * 100}%`;
const valEl = fader.querySelector('.mod-fader__v') as HTMLElement | null;
if (valEl) valEl.textContent = String(v);
}
}
export async function saveCardBrightness(deviceId: any, value: any) {
@@ -649,11 +789,17 @@ export async function fetchDeviceBrightness(deviceId: any) {
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
// Sync the mod-fader's fill + numeric readout via the
// shared visual-update helper.
updateBrightnessLabel(deviceId, data.brightness);
}
// Legacy wrapper (pre-migration cards)
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading');
// mod-card variant — loading flag lives on the card itself
const card = slider?.closest('.card.mod-card') as HTMLElement | null;
if (card) card.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
} finally {
@@ -25,33 +25,61 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox')!;
const canvas = document.getElementById('display-picker-canvas')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
if (titleEl) {
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
// Device-picker variants (camera, scrcpy) drop the big "Select Display"
// title — the eyebrow channel strip already reads "Device · List", so
// a redundant title rack just steals vertical space. The title only
// renders in monitor-pick mode.
const isDevicePicker = !!_pickerEngineType;
const content = lightbox.querySelector('.display-picker-content');
content?.classList.toggle('display-picker-content--no-title', isDevicePicker);
const setI18n = (selector: string, key: string) => {
const el = lightbox.querySelector(selector);
if (!el) return;
el.setAttribute('data-i18n', key);
el.textContent = t(key);
};
if (!isDevicePicker) {
setI18n('.display-picker-title [data-role="lead"]', 'displays.picker.title.lead');
setI18n('.display-picker-title__accent', 'displays.picker.title.accent');
}
setI18n('.display-picker-eyebrow [data-role="label"]', 'displays.picker.eyebrow.label');
setI18n(
'.display-picker-eyebrow__channel',
isDevicePicker ? 'displays.picker.eyebrow.channel.device' : 'displays.picker.eyebrow.channel',
);
setI18n(
'.display-picker-foot [data-role="select"]',
isDevicePicker ? 'displays.picker.foot.select.device' : 'displays.picker.foot.select',
);
// Render synchronously *before* activating the modal so the first frame
// shows final content (or a spinner) instead of stale layout from the
// previous open.
if (_pickerEngineType) {
canvas.innerHTML = '<div class="loading-spinner"></div>';
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = '<div class="loading-spinner"></div>';
}
lightbox.classList.add('active');
requestAnimationFrame(() => {
// Always fetch fresh when engine type is specified (different list each time)
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
// Kick off async fetches after activation; spinner is already in place.
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (!_cachedDisplays || _cachedDisplays.length === 0) {
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
@@ -4,7 +4,7 @@
*/
import { t } from '../core/i18n.ts';
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
import { ICON_HEART, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
// ─── Config ─────────────────────────────────────────────────
@@ -61,40 +61,53 @@ export function snoozeDonation(): void {
_hideBanner();
}
/** Render the About panel content in settings modal. */
/** Render the About panel content in settings modal.
* Uses the Lumenworks rack-panel + about-hero pattern from the
* settings-modal-redesign mockup: a channel-coded .ds-section
* wrapping a centered hero with mark, name, version pill, tagline,
* and external-link buttons. */
export function renderAboutPanel(): void {
const container = document.getElementById('about-panel-content');
if (!container) return;
const version = document.getElementById('version-number')?.textContent || '';
let links = '';
const version = document.getElementById('version-number')?.textContent?.trim() || '';
const linkButtons: string[] = [];
if (_repoUrl) {
links += `<a href="${_repoUrl}" target="_blank" rel="noopener" class="about-link">
${ICON_GITHUB}
<span>${t('donation.view_source')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
linkButtons.push(
`<a href="${_repoUrl}" target="_blank" rel="noopener" class="btn">
${ICON_GITHUB}
<span>${t('donation.view_source')}</span>
</a>`,
);
}
if (_donateUrl) {
links += `<a href="${_donateUrl}" target="_blank" rel="noopener" class="about-link about-link-donate">
${ICON_HEART}
<span>${t('donation.about_donate')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
linkButtons.push(
`<a href="${_donateUrl}" target="_blank" rel="noopener" class="btn">
${ICON_HEART}
<span>${t('donation.about_donate')}</span>
</a>`,
);
}
container.innerHTML = `
<div class="about-section">
<div class="about-logo">${ICON_HEART}</div>
<h3 class="about-title">${t('donation.about_title')}</h3>
${version ? `<span class="about-version">${version}</span>` : ''}
<p class="about-text">${t('donation.about_opensource')}</p>
${links ? `<div class="about-links">${links}</div>` : ''}
<p class="about-license">${t('donation.about_license')}</p>
</div>
<section class="ds-section" data-ch="amber">
<div class="ds-section-body">
<div class="about-hero">
<div class="about-mark" aria-hidden="true">L</div>
<div class="about-name">${t('donation.about_title')}</div>
${version ? `<div class="about-version">${version}</div>` : ''}
<div class="about-tag">${t('donation.about_opensource')}</div>
<div class="about-author">
${t('donation.about_author')} <strong>Alexei Dolgolyov</strong>
<span class="about-author-sep">·</span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
</div>
${linkButtons.length ? `<div class="about-links">${linkButtons.join('')}</div>` : ''}
<div class="about-license">${t('donation.about_license')}</div>
</div>
</div>
</section>
`;
}
@@ -12,6 +12,7 @@ import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import {
@@ -540,34 +541,54 @@ export function testGameConnection() {
// ── Card renderer ──
export function createGameIntegrationCard(gi: GameIntegration): string {
const adapterIcon = getGameAdapterIcon(gi.adapter_type);
const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type;
const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
const mappingCount = gi.event_mappings?.length || 0;
const isEnabled = !!gi.enabled;
return wrapCard({
type: 'template-card',
dataAttr: 'data-gi-id',
id: gi.id,
removeOnclick: `deleteGameIntegration('${gi.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
</div>
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span>
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''}
</div>
${renderTagChips(gi.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
// Badge: GAME · {ADAPTER} — adapter type compressed to 4-6 chars uppercase
const adapterBadge = String(gi.adapter_type).toUpperCase().slice(0, 8);
const badgeText = `GAME · ${adapterBadge}`;
const leds: LedState[] = isEnabled ? ['on'] : ['off'];
const chips: ModChipOpts[] = [
{ icon: ICON_GAMEPAD, text: adapterName, title: t('game_integration.adapter') },
{ icon: ICON_CIRCLE_DOT, text: enabledLabel, title: t('game_integration.status'), variant: isEnabled ? 'tag' : 'default' },
];
if (mappingCount > 0) {
chips.push({ icon: _icon(P.listChecks), text: `${mappingCount} ${t('game_integration.mappings') || 'mappings'}`, title: t('game_integration.mappings') });
}
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: gi.name,
metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
leds,
menu: {
duplicateOnclick: `cloneGameIntegration('${gi.id}')`,
hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`,
deleteOnclick: `deleteGameIntegration('${gi.id}')`,
},
},
body: {
desc: gi.description || undefined,
chips,
},
foot: {
patchState: isEnabled ? 'live' : 'idle',
patchLabel: isEnabled ? 'ARMED' : 'DISARMED',
iconActions: [
{ icon: ICON_TEST, onclick: `event.stopPropagation(); showGameEventMonitor('${gi.id}')`, title: t('game_integration.events.monitor') },
{ icon: ICON_EDIT, onclick: `showGameIntegrationEditor('${gi.id}')`, title: t('common.edit') },
],
},
running: isEnabled,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-gi-id', id: gi.id, mod });
const tagsHtml = renderTagChips(gi.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
// ── CRUD ──

Some files were not shown because too many files have changed in this diff Show More