Compare commits

...

6 Commits

Author SHA1 Message Date
alexei.dolgolyov c44bb38c43 docs(release): refresh v0.4.2 notes with fix(release) and refactor commits
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m5s
Build Release / build-linux (push) Successful in 4m53s
Build Release / build-docker (push) Successful in 5m39s
Build Release / build-windows (push) Successful in 6m55s
Lint & Test / test (push) Successful in 7m13s
2026-04-22 20:20:30 +03:00
alexei.dolgolyov be2d5e1670 refactor(color-strips): move Key Colors test from lightbox into test-css-source modal
Lint & Test / test (push) Successful in 6m37s
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.

- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
  longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
  lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
2026-04-22 20:18:46 +03:00
alexei.dolgolyov 5db6eddcf8 fix(release): ship prebuilt assets and bump fallback version
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.

- .gitignore: anchor /data/ and /server/data/ so nested package data
  dirs are not ignored.
- Track previously-excluded shipped assets:
  - server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
  - server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
  The Windows installer strips ledgrab-*.dist-info, so
  importlib.metadata falls back to this literal — which is why
  v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
  build-dist.ps1 so future drift is auto-corrected by the build.
2026-04-22 20:17:10 +03:00
alexei.dolgolyov a8a4296a56 chore: release v0.4.2
Build Android APK / build-android (push) Failing after 1m48s
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 3m58s
Build Release / build-docker (push) Successful in 5m6s
Build Release / build-windows (push) Successful in 5m54s
Lint & Test / test (push) Successful in 6m14s
2026-04-22 19:48:37 +03:00
alexei.dolgolyov 9ce1dc33bf feat(ui): restyle enhanced header locale picker as LED-accent badge 2026-04-22 19:48:08 +03:00
alexei.dolgolyov 03d2e6b1f2 ci(release): publish .sha256 sidecars alongside release assets
Lint & Test / test (push) Successful in 2m4s
The in-app update service (`ledgrab.core.update.update_service`) refuses
to install any downloaded artifact that has no published sha256 — either
as a sibling `<asset>.sha256` asset on the Gitea release, or embedded in
the release body. The release workflow uploaded the ZIP, setup.exe, and
Linux tarball but never published checksums, so every auto-update 500'd
with "Update checksum unavailable; install aborted".

Generate sha256sum sidecars for the Windows ZIP, Windows setup.exe, and
Linux tar.gz and upload them next to the primary asset on each tagged
release. Existing v0.4.x releases stay broken — ship v0.4.2 (or manually
upload sidecars to v0.4.1) to unblock in-app updates.
2026-04-22 19:40:46 +03:00
24 changed files with 626 additions and 190 deletions
+37 -19
View File
@@ -191,11 +191,21 @@ jobs:
echo "Uploaded: $NAME"
}
# Publish an asset plus its .sha256 sidecar. The in-app update
# service refuses to install without a published checksum, so
# every artifact needs its hash uploaded alongside.
upload_with_sha256() {
local FILE="$1"
upload_asset "$FILE"
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
upload_asset "$FILE.sha256"
}
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
# ── Linux tarball ──────────────────────────────────────────
build-linux:
@@ -242,26 +252,34 @@ jobs:
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local FILE="$1"
local NAME
NAME=$(basename "$FILE")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $NAME"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
echo "Uploaded: $NAME"
}
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
TAR_NAME=$(basename "$TAR_FILE")
# Delete existing asset with same name to prevent duplicates on re-run
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $TAR_NAME"
if [ -f "$TAR_FILE" ]; then
upload_asset "$TAR_FILE"
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
upload_asset "$TAR_FILE.sha256"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$TAR_FILE"
echo "Uploaded: $TAR_NAME"
# ── Docker image ───────────────────────────────────────────
build-docker:
needs: create-release
+6 -2
View File
@@ -62,8 +62,12 @@ htmlcov/
logs/
*.log.*
# Runtime data
data/
# Runtime data — anchor to repo root so nested package data dirs
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
# shipped sound assets out of the CI tag checkout.
/data/
/server/data/
*.db
*.sqlite
*.json.bak
+12 -10
View File
@@ -1,18 +1,20 @@
## v0.4.1 (2026-04-22)
## v0.4.2 (2026-04-22)
### Bug Fixes
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
- Ship previously-missing package assets in release artifacts — prebuilt notification sounds (`alert`, `bell`, `chime`, `ping`, `pop`) and game adapter YAMLs (`minecraft`, `rocket_league`, `valorant`). An unanchored `data/` rule in `.gitignore` was matching `server/src/ledgrab/data/`, so these files never reached the tag or CI builds. Also bump the `_FALLBACK_VERSION` literal to `0.4.2` so the Windows installer (which strips `.dist-info`) reports the correct version in the WebUI instead of `0.3.0`. Build scripts now patch this literal automatically to prevent future drift. ([5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd))
### Features
- Restyle the enhanced header locale picker as a LED-accent badge — 2-letter code in Orbitron, collapses to just the badge on narrow screens ([9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3))
---
### Development / Internal
#### CI/Build
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
- Publish `.sha256` sidecars alongside release assets for easier integrity verification ([03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b))
#### Documentation
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d))
#### Refactoring
- Move the Key Colors test out of the lightbox and into the `test-css-source` modal where the rest of the source-render debug tools live ([be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1))
---
@@ -21,9 +23,9 @@
| Hash | Message | Author |
|------|---------|--------|
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov |
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov |
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov |
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov |
| [be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1) | refactor(color-strips): move Key Colors test from lightbox into test-css-source modal | alexei.dolgolyov |
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | alexei.dolgolyov |
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | alexei.dolgolyov |
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | alexei.dolgolyov |
</details>
+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.4.1"
versionName = "0.4.2"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+10
View File
@@ -69,6 +69,16 @@ copy_app_files() {
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Patch the fallback version in the bundled __init__.py. Bundled installs
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
# falls back to this literal at runtime — and a stale literal is what
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
fi
}
# ── Site-packages cleanup ────────────────────────────────────
+11
View File
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Patch the fallback version in the bundled __init__.py so the WebUI always
# reports the release version — the installer strips ledgrab-*.dist-info from
# site-packages (above), so importlib.metadata falls back to this literal.
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
if (Test-Path $bundledInit) {
$initContent = Get-Content $bundledInit -Raw
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
Set-Content -Path $bundledInit -Value $patched -NoNewline
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
}
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.4.1"
version = "0.4.2"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+7 -3
View File
@@ -2,10 +2,14 @@
from importlib.metadata import version, PackageNotFoundError
# Fallback version — kept in sync with pyproject.toml.
# Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets).
_FALLBACK_VERSION = "0.3.0"
# on Android, where the source is included directly via source sets, or
# in the Windows bundle where the installed dist-info is stripped).
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab")
@@ -0,0 +1,78 @@
# Minecraft community adapter
# Requires a server-side mod that sends game state via webhook
# (e.g., GameStateIntegration mod or custom Fabric/Forge mod)
#
# Configure your mod to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: minecraft
game: Minecraft
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 20
trigger: on_change
- source_path: player.armor
event: armor
min: 0
max: 20
trigger: on_change
- source_path: player.food_level
event: energy
min: 0
max: 20
trigger: on_change
- source_path: player.experience_level
event: speed
min: 0
max: 100
trigger: on_change
- source_path: player.deaths
event: death
trigger: on_increase
min: 0
max: 100
- source_path: stats.kills
event: kill
trigger: on_increase
min: 0
max: 100
auth:
type: header
header: X-Minecraft-Auth
setup_instructions: |
## Minecraft Integration Setup
This adapter requires a server-side mod that sends game state data as JSON.
**Recommended mods:**
- [GameStateIntegration](https://github.com/example/gsi-mod) (Fabric)
- Custom Forge mod using `PlayerTickEvent`
**Expected JSON format:**
```json
{
"player": {
"health": 20.0,
"armor": 10,
"food_level": 18,
"experience_level": 30
},
"stats": {
"kills": 5
}
}
```
Configure the mod to POST to the event endpoint with the auth token
in the `X-Minecraft-Auth` header.
@@ -0,0 +1,99 @@
# Rocket League community adapter
# Uses the SOS (Rocket League Overlay System) plugin
# https://gitlab.com/bakkesplugins/sos/sos-plugin
#
# SOS sends game state via WebSocket, but you can use a bridge
# to forward events as HTTP POST to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: rocket_league
game: Rocket League
protocol: webhook
mappings:
- source_path: player.boost
event: energy
min: 0
max: 100
trigger: on_change
- source_path: player.speed
event: speed
min: 0
max: 2300
trigger: on_value
- source_path: match.goals_scored
event: kill
trigger: on_increase
min: 0
max: 20
- source_path: match.goals_conceded
event: death
trigger: on_increase
min: 0
max: 20
- source_path: match.time_remaining
event: objective_progress
min: 0
max: 300
trigger: on_value
- source_path: game.started
event: match_start
trigger: on_change
min: 0
max: 1
- source_path: game.ended
event: match_end
trigger: on_change
min: 0
max: 1
- source_path: team.score_blue
event: team_a
min: 0
max: 10
trigger: on_change
- source_path: team.score_orange
event: team_b
min: 0
max: 10
trigger: on_change
setup_instructions: |
## Rocket League Integration Setup
This adapter works with the SOS (Rocket League Overlay System) plugin.
**Setup:**
1. Install BakkesMod: https://bakkesmod.com
2. Install the SOS plugin from the BakkesMod plugin manager
3. Use a WebSocket-to-HTTP bridge to forward SOS events
**Bridge tool:**
A small script that connects to SOS WebSocket (ws://localhost:49122)
and forwards events as HTTP POST to the WLED event endpoint.
**Expected JSON format:**
```json
{
"player": {
"boost": 75,
"speed": 1500
},
"match": {
"goals_scored": 2,
"goals_conceded": 1,
"time_remaining": 180
},
"team": {
"score_blue": 2,
"score_orange": 1
}
}
```
@@ -0,0 +1,85 @@
# Valorant community adapter
# Uses Overwolf/Insights API or third-party overlay tool
# that exposes game state via webhook
#
# Configure your overlay to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: valorant
game: Valorant
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 100
trigger: on_change
- source_path: player.shield
event: shield
min: 0
max: 50
trigger: on_change
- source_path: player.money
event: gold
min: 0
max: 9000
trigger: on_change
- source_path: match.kills
event: kill
trigger: on_increase
min: 0
max: 50
- source_path: match.deaths
event: death
trigger: on_increase
min: 0
max: 50
- source_path: match.round_phase
event: round_start
trigger: on_change
min: 0
max: 1
- source_path: match.spike_planted
event: objective_captured
trigger: on_change
min: 0
max: 1
auth:
type: header
header: X-Valorant-Auth
setup_instructions: |
## Valorant Integration Setup
Valorant does not have a native Game State Integration API.
You need a third-party tool to capture and forward game data.
**Options:**
- Overwolf with a game events plugin
- Insights.gg capture API
- Custom screen-reading overlay
**Expected JSON format:**
```json
{
"player": {
"health": 100,
"shield": 50,
"money": 3900
},
"match": {
"kills": 12,
"deaths": 5,
"round_phase": 1,
"spike_planted": 0
}
}
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
View File
@@ -100,6 +100,61 @@ h2 {
background: var(--bg-secondary);
}
/* ── Header locale picker (post IconSelect enhancement) ────────────────────
The hidden <select.header-locale> is enhanced into a trigger button at
runtime. Inside the toolbar, re-skin it to match .header-btn so it reads
as a peer of the icon buttons, with the 2-letter code rendered as a small
LED-style accent badge in Orbitron — same display font as the brand mark. */
.header-toolbar .icon-select-trigger {
width: auto;
gap: 6px;
padding: 3px 6px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: color 0.2s, background 0.2s;
}
.header-toolbar .icon-select-trigger:hover {
color: var(--text-color);
background: var(--bg-secondary);
border-color: transparent;
}
.header-toolbar .icon-select-trigger-icon {
font-family: 'Orbitron', sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--primary-color);
padding: 3px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
line-height: 1;
}
.header-toolbar .icon-select-trigger-icon > span {
font-weight: inherit;
}
.header-toolbar .icon-select-trigger-label {
flex: 0 1 auto;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.01em;
margin: 0;
}
.header-toolbar .icon-select-trigger-arrow {
font-size: 0.6rem;
opacity: 0.55;
margin-left: 1px;
}
#server-version {
font-family: 'Orbitron', sans-serif;
font-size: 0.65rem;
+10
View File
@@ -140,6 +140,16 @@
max-width: 48px;
}
/* Collapse the enhanced locale trigger to just the 2-letter badge on
narrow screens, matching the compact footprint of the icon buttons. */
.header-toolbar .icon-select-trigger-label {
display: none;
}
.header-toolbar .icon-select-trigger {
gap: 4px;
padding: 3px 5px;
}
h1 {
font-size: 1.1rem;
}
+65 -52
View File
@@ -274,6 +274,71 @@
display: block;
}
/* Key Colors test view — frame with region overlays */
.css-test-kc-wrap {
position: relative;
width: 100%;
border-radius: 4px;
overflow: hidden;
background: #000;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.css-test-kc-canvas {
display: block;
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
}
.css-test-kc-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 12px;
padding: 8px 0 2px;
font-size: 0.8rem;
color: var(--text-color-muted, #aaa);
}
.css-test-kc-swatch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px 2px 2px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-color, #333);
border-radius: 999px;
font-size: 0.78rem;
color: var(--text-color, #e0e0e0);
}
.css-test-kc-swatch-chip {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.css-test-kc-swatch-hex {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
opacity: 0.65;
}
.css-test-kc-mode {
opacity: 0.7;
font-variant: small-caps;
letter-spacing: 0.04em;
}
.css-test-status {
text-align: center;
padding: 8px 0;
@@ -1246,58 +1311,6 @@
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn {
position: absolute;
top: 16px;
right: 64px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 1.2rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 1;
}
.lightbox-refresh-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn.active {
background: var(--primary-color);
}
.lightbox-fps-select {
position: absolute;
top: 16px;
right: 116px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
padding: 4px 6px;
font-size: 0.8rem;
cursor: pointer;
z-index: 1;
appearance: none;
-webkit-appearance: none;
text-align: center;
}
.lightbox-fps-select:hover {
background: rgba(255, 255, 255, 0.15);
}
.lightbox-fps-select:focus {
outline: 1px solid var(--primary-color);
}
.lightbox-stats {
position: absolute;
bottom: 8px;
+1 -4
View File
@@ -22,7 +22,7 @@ import {
toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showUndoToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
setFieldError, clearFieldError, setupBlurValidation, initLightbox,
setFieldError, clearFieldError, setupBlurValidation,
} from './core/ui.ts';
// Layer 3: displays, tutorials
@@ -754,9 +754,6 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize command palette
initCommandPalette();
// Enhance lightbox FPS <select> with IconSelect
initLightbox();
// Setup form handler
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
-24
View File
@@ -5,30 +5,6 @@
import { confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
import { IconSelect } from './icon-select.ts';
let _lightboxFpsIconSelect: IconSelect | null = null;
/** Enhance the lightbox FPS <select> with an IconSelect. Idempotent. */
export function initLightbox(): void {
if (_lightboxFpsIconSelect) return;
const sel = document.getElementById('lightbox-fps-select') as HTMLSelectElement | null;
if (!sel) return;
_lightboxFpsIconSelect = new IconSelect({
target: sel,
items: [
{ value: '1', icon: '<span style="font-weight:700">1</span>', label: '1 fps' },
{ value: '2', icon: '<span style="font-weight:700">2</span>', label: '2 fps' },
{ value: '3', icon: '<span style="font-weight:700">3</span>', label: '3 fps' },
{ value: '5', icon: '<span style="font-weight:700">5</span>', label: '5 fps' },
],
columns: 2,
onChange: (val: string) => {
const fn = (window as any).onLightboxFpsChange;
if (typeof fn === 'function') fn(val);
},
});
}
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
export function isTouchDevice() {
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
import { logError } from '../../core/log.ts';
import { colorStripSourcesCache } from '../../core/state.ts';
import { t } from '../../core/i18n.ts';
import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts';
import { showToast } from '../../core/ui.ts';
import { createFpsSparkline } from '../../core/chart-utils.ts';
import {
getColorStripIcon,
@@ -97,6 +97,10 @@ let _cssTestTransientConfig: any = null;
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
const _CSS_TEST_KC_FPS_KEY = 'css_test_kc_fps';
const _CSS_TEST_KC_FPS_DEFAULT = 5;
const _CSS_TEST_KC_FPS_MIN = 1;
const _CSS_TEST_KC_FPS_MAX = 30;
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
@@ -109,6 +113,7 @@ let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestIsKeyColors: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
@@ -125,6 +130,11 @@ function _getCssTestFps() {
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _getKCTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_KC_FPS_KEY) ?? '', 10);
return (stored >= _CSS_TEST_KC_FPS_MIN && stored <= _CSS_TEST_KC_FPS_MAX) ? stored : _CSS_TEST_KC_FPS_DEFAULT;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
@@ -162,81 +172,139 @@ export function testColorStrip(sourceId: string) {
}
let _kcTestWs: WebSocket | null = null;
const _kcTestCanvas = document.createElement('canvas');
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
img.style.display = 'none'; // Hide until first frame arrives
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestIsApiInput = false;
_cssTestIsKeyColors = true;
_cssTestSourceId = sourceId;
// Close any previous WS
// Close any previous sessions
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
// Show only the KC view; hide all others
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-kc-view') as HTMLElement).style.display = '';
(document.getElementById('css-test-fps-chart-group') as HTMLElement).style.display = 'none';
// CSPT input selector is not relevant for KC
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
// LED count doesn't apply to KC — hide LED group; keep FPS input visible
(document.getElementById('css-test-led-fps-group') as HTMLElement).style.display = '';
(document.getElementById('css-test-led-group') as HTMLElement).style.display = 'none';
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
if (fpsInput) {
fpsInput.min = String(_CSS_TEST_KC_FPS_MIN);
fpsInput.max = String(_CSS_TEST_KC_FPS_MAX);
fpsInput.value = String(_getKCTestFps());
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
// Widen modal to give the frame room to breathe
const modalContent = modal.querySelector('.modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '900px';
// Clear any stale KC state
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (metaEl) metaEl.innerHTML = '';
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.connecting');
statusEl.style.display = '';
_kcTestConnect(sourceId, _getKCTestFps());
}
function _kcTestConnect(sourceId: string, fps: number) {
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
// Build WS URL
const gen = ++_cssTestGeneration;
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=5&preview_width=960`;
const clamped = Math.max(_CSS_TEST_KC_FPS_MIN, Math.min(_CSS_TEST_KC_FPS_MAX, fps));
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=${clamped}&preview_width=960`;
openAuthedWs(wsUrl).then((ws) => {
if (gen !== _cssTestGeneration) { ws.close(); return; }
_kcTestWs = ws;
ws.onmessage = (ev) => {
if (gen !== _cssTestGeneration) return;
try {
const data = JSON.parse(ev.data);
if (data.type === 'frame') {
_renderKCTestFrame(data);
const statusEl = document.getElementById('css-test-status') as HTMLElement | null;
if (statusEl) statusEl.style.display = 'none';
}
} catch (err) { logError('color-strips.test.kcWs.message', err); }
};
ws.onerror = () => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
};
ws.onclose = () => {
ws.onclose = (ev) => {
if (gen !== _cssTestGeneration) return;
_kcTestWs = null;
};
// Stop WS when lightbox closes
const origClose = (window as any).closeLightbox;
lightbox.onclick = (e) => {
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
closeLightbox();
if (ev.reason) {
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = ev.reason;
statusEl.style.display = '';
}
};
}).catch(() => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
});
}
function _renderKCTestFrame(data: any) {
const rects = data.rectangles || [];
const mode = data.interpolation_mode || 'average';
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (!canvas) return;
// Draw frame + rectangles onto offscreen canvas
const tmpImg = new Image();
tmpImg.onload = () => {
_kcTestCanvas.width = tmpImg.naturalWidth;
_kcTestCanvas.height = tmpImg.naturalHeight;
const ctx = _kcTestCanvas.getContext('2d')!;
canvas.width = tmpImg.naturalWidth;
canvas.height = tmpImg.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(tmpImg, 0, 0);
rects.forEach((r: any, i: number) => {
const x = r.x * _kcTestCanvas.width;
const y = r.y * _kcTestCanvas.height;
const w = r.width * _kcTestCanvas.width;
const h = r.height * _kcTestCanvas.height;
const x = r.x * canvas.width;
const y = r.y * canvas.height;
const w = r.width * canvas.width;
const h = r.height * canvas.height;
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
ctx.fillStyle = r.color.hex + '33';
@@ -258,36 +326,19 @@ function _renderKCTestFrame(data: any) {
ctx.lineWidth = 1;
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
});
// Update lightbox image directly (use data URL for full-size display)
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
if (lbImg) {
lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9);
lbImg.style.display = '';
lbImg.style.maxWidth = '100%';
lbImg.style.width = '100%';
}
// Hide spinner after first frame
const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
// Update swatches
const statsEl = document.getElementById('lightbox-stats')!;
const swatches = rects.map((r: any) =>
`<div style="display:inline-flex;align-items:center;gap:6px;margin:4px 8px;">
<span style="display:inline-block;width:20px;height:20px;background:${r.color.hex};border:1px solid #888;border-radius:3px;"></span>
<span>${escapeHtml(r.name)}</span>
<small style="opacity:0.6;">${r.color.hex}</small>
</div>`
).join('');
statsEl.innerHTML = `
<div style="display:flex;flex-wrap:wrap;justify-content:center;">${swatches}</div>
<div style="margin-top:4px;opacity:0.6;text-align:center;">Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}</div>
`;
statsEl.style.display = '';
};
tmpImg.src = data.image;
if (metaEl) {
const swatches = rects.map((r: any) =>
`<span class="css-test-kc-swatch">
<span class="css-test-kc-swatch-chip" style="background:${r.color.hex}"></span>
<span>${escapeHtml(r.name)}</span>
<span class="css-test-kc-swatch-hex">${r.color.hex}</span>
</span>`
).join('');
metaEl.innerHTML = `${swatches}<span class="css-test-kc-mode">${mode} · ${rects.length}&nbsp;region${rects.length !== 1 ? 's' : ''}</span>`;
}
}
export async function testCSPT(templateId: string) {
@@ -310,10 +361,12 @@ export async function testCSPT(templateId: string) {
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
@@ -326,6 +379,8 @@ function _openTestModal(sourceId: string) {
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
const kcView = document.getElementById('css-test-kc-view') as HTMLElement | null;
if (kcView) kcView.style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
@@ -363,8 +418,12 @@ function _openTestModal(sourceId: string) {
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
if (fpsInput) {
fpsInput.min = '1';
fpsInput.max = '60';
fpsInput.value = String(fpsVal);
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
_cssTestConnect(sourceId, ledCount, fpsVal);
}
@@ -621,6 +680,18 @@ function _cssTestUpdateBrightness(values: any) {
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
// Key Colors test: FPS only — different range and storage key
if (_cssTestIsKeyColors) {
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < _CSS_TEST_KC_FPS_MIN) fps = _CSS_TEST_KC_FPS_MIN;
if (fps > _CSS_TEST_KC_FPS_MAX) fps = _CSS_TEST_KC_FPS_MAX;
if (fpsInput) fpsInput.value = String(fps);
localStorage.setItem(_CSS_TEST_KC_FPS_KEY, String(fps));
_kcTestConnect(_cssTestSourceId, fps);
return;
}
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
@@ -1060,11 +1131,13 @@ function _cssTestStopFpsSampling() {
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
@@ -44,6 +44,14 @@
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- Key Colors view (frame + region overlays) -->
<div id="css-test-kc-view" style="display:none">
<div class="css-test-kc-wrap">
<canvas id="css-test-kc-canvas" class="css-test-kc-canvas"></canvas>
</div>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div>
</div>
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
@@ -1,13 +1,6 @@
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">&#x25B6;</button>
<select id="lightbox-fps-select" class="lightbox-fps-select" style="display:none" title="Frames per second">
<option value="1">1 fps</option>
<option value="2">2 fps</option>
<option value="3" selected>3 fps</option>
<option value="5">5 fps</option>
</select>
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>