Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c44bb38c43 | |||
| be2d5e1670 | |||
| 5db6eddcf8 | |||
| a8a4296a56 | |||
| 9ce1dc33bf | |||
| 03d2e6b1f2 |
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} 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">✕</button>
|
||||
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">▶</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>
|
||||
|
||||
Reference in New Issue
Block a user