1
0

Dynamic key generators: plugin system for auto-generated pages

Add dynamic_keys support to PageConfig — a contract-based plugin
system where any executable outputs a JSON array of KeyConfig to stdout.
The daemon runs the generator on a configurable interval and renders
the keys dynamically on the Stream Deck.

Core changes:
- config: DynamicKeyGen struct (command/script, interval, timeout, max_keys)
- config: full validation for dynamic_keys fields
- action: execDynamicKeys() with safe JSON parsing (stderr logged separately,
  env vars STREAMDECK_KEY_COUNT / STREAMDECK_CONFIG_DIR)
- page: activeKeys() merges static + dynamic keys (static wins by index)
- page: startDynamicKeys / stopDynamicKeys lifecycle, per-page results cache
- page: rerenderActivePage() with deadlock-safe lock ordering
- daemon: event handler uses activeKeys() for dynamic key actions

Web UI:
- GET /api/pages returns effective_keys (merged static + cached dynamic)
- app.js: gridKeys uses effective_keys when available
- app.js: guard against editing dynamic keys through the UI
- index.html: " dynamic" badge + effective key count in page switcher
- polls /api/pages every 10s to keep the UI in sync

Image rendering:
- loadImage: gift.ResizeToFill instead of gift.Resize (preserves aspect
  ratio, crops to fill — no more stretched cover art)

Example & docs:
- config.example.json: games page with dynamic_keys + back button
- README.md: comprehensive Dynamic Key Generators section (architecture
  diagram, config reference, generator contract, merge behavior,
  env vars, error handling, Python + Shell examples, tips)
This commit is contained in:
Maksim Totmin 2026-06-21 21:05:32 +07:00
parent ac175fb144
commit 8df8b65163
9 changed files with 581 additions and 43 deletions

207
README.md
View File

@ -127,6 +127,213 @@ Server down!
If no color is specified, the key uses its configured background or the default black. If no color is specified, the key uses its configured background or the default black.
### Dynamic Key Generators
Generate an entire page of keys dynamically by running any executable (shell script, Python, Go binary, etc.). Useful for game launchers, music collections, Docker containers, monitoring dashboards — anything where the set of keys is not known at config time.
```
┌──────────────────────────────────────────────────┐
│ Generator script (any language) │
│ → queries Lutris DB, Steam API, etc. │
│ → outputs JSON array of KeyConfig to stdout │
│ → receives STREAMDECK_KEY_COUNT env var │
└──────────────┬───────────────────────────────────┘
│ stdout: [{"index":0,"icon":"...",...}]
┌──────────────────────────────────────────────────┐
│ streamdeck-lets-go daemon │
│ → parses JSON │
│ → merges with static keys (static wins) │
│ → renders keys on the deck │
│ → re-runs on interval (default 60s) │
└──────────────────────────────────────────────────┘
```
#### Config
Add `dynamic_keys` to any page:
```json
{
"name": "Games",
"icon": "fa:gamepad",
"keys": [
{
"index": 0,
"icon": "fa:arrow-left",
"actions": [
{ "trigger": "tap", "type": "page", "page": "main" }
]
}
],
"dynamic_keys": {
"command": "/home/user/bin/game-list",
"interval": "120s",
"timeout": "15s",
"max_keys": 14
}
}
```
| Field | Type | Default | Description |
|---|---|---|---|
| `command` | string | — | Shell command to execute (mutually exclusive with `script`) |
| `script` | string | — | Path to an executable script; relative paths resolve against `~/.config/streamdeck-lets-go/` |
| `interval` | string | `"60s"` | How often to re-run the generator (minimum 1s, maximum 24h) |
| `timeout` | string | `"15s"` | Maximum execution time before the generator is killed |
| `max_keys` | int | `deck.NumKeys()` | Maximum number of keys to accept (e.g. `13` to reserve 2 for navigation) |
#### Generator contract
The script **must** print a JSON array of key configs to stdout and exit with code 0. Each element supports the full `KeyConfig` schema:
```json
[
{
"index": 0,
"icon": "/home/user/.local/share/lutris/coverart/cyberpunk-2077.jpg",
"label": "Cyberpunk 2077",
"font_size": 12,
"icon_scale": 1.0,
"background": "#222222",
"actions": [
{
"trigger": "tap",
"type": "command",
"command": "lutris lutris:rungame/cyberpunk-2077",
"background": true
}
]
}
]
```
Every field from a static key is available:
| Field | Type | Description |
|---|---|---|
| `index` | int | Key position on the deck (0-based) |
| `icon` | string | Icon source: `fa:terminal`, `emoji:fire`, `@firefox`, `/absolute/path.png`, relative path |
| `label` | string | Text shown below the icon |
| `font_size` | number | Font size in points (default 18, recommended 1114 for labels) |
| `icon_scale` | number | Icon scale factor (0.01.0, default 0.55) |
| `background` | string | Hex background color `"#222222"` |
| `actions` | array | Same action schema as static keys — supports all triggers (`tap`, `long_press`, `double_tap`) and all action types (`command`, `builtin`, `script`, `page`, `keyboard`) |
| `display` | object | Periodic display block (same as static key `display`) — command/script runs on interval and text is overlaid on the key |
#### Merge behavior
Static `keys` (defined directly on the page) and dynamic keys are **merged** at runtime:
| Priority | Source |
|---|---|
| 1 (highest) | Static keys — their indices are reserved |
| 2 | Dynamic keys — fill indices not occupied by static keys |
This lets you keep navigation buttons fixed while the generator fills the rest of the deck:
```
Static: [← back] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
index 0 └────────────────────── dynamic (indices 114) ──────────────────────→
```
#### Environment variables
The generator receives these environment variables:
| Variable | Example | Description |
|---|---|---|
| `STREAMDECK_KEY_COUNT` | `15` | Maximum number of keys to output (derived from deck model or `max_keys` config) |
| `STREAMDECK_CONFIG_DIR` | `/home/user/.config/streamdeck-lets-go` | Path to the config directory — useful for finding companion files |
#### Error handling
| Situation | Behavior |
|---|---|
| Script fails (non-zero exit, timeout) | Warning logged; previous keys preserved |
| Script returns empty array `[]` | Only static keys rendered |
| Script returns invalid JSON | Warning logged; previous keys preserved |
| Script outputs debug to stderr | Logged at DEBUG level; stdout JSON parsed cleanly |
| More keys than `max_keys` | Excess keys silently dropped |
| Generator updates while page is active | Keys re-rendered in place (no flicker) |
#### Examples
**Python — Lutris game launcher**
Place this at `~/.config/streamdeck-lets-go/scripts/lutris-keys`, make executable (`chmod +x`).
```python
#!/usr/bin/env python3
import json, os, sqlite3, pathlib
db = pathlib.Path.home() / '.local/share/lutris/pga.db'
cover = pathlib.Path.home() / '.local/share/lutris/coverart'
max_keys = int(os.environ.get('STREAMDECK_KEY_COUNT', 15))
if not db.exists():
print('[]')
exit(0)
conn = sqlite3.connect(db)
games = conn.execute(
"SELECT slug, name FROM games WHERE installed = 1 ORDER BY name"
).fetchall()
conn.close()
keys = []
for idx, (slug, name) in enumerate(games):
if idx >= max_keys:
break
icon = next(cover.glob(f'{slug}.*'), None)
key = {
"index": idx,
"icon_scale": 1.0,
"actions": [{
"trigger": "tap",
"type": "command",
"command": f"lutris lutris:rungame/{slug}",
"background": True
}]
}
if icon:
key["icon"] = str(icon)
keys.append(key)
print(json.dumps(keys))
```
Reference it in config:
```json
{
"name": "Games",
"dynamic_keys": {
"script": "scripts/lutris-keys",
"interval": "120s"
}
}
```
**Shell — Simple test generator**
```bash
#!/bin/sh
for i in $(seq 0 $((STREAMDECK_KEY_COUNT - 1))); do
[ "$i" -gt 0 ] && echo ","
printf '{"index":%d,"icon":"fa:hashtag","label":"Item %d"}' "$i" "$i"
done
```
Run with any language you like — the only requirement is a valid JSON array on stdout.
#### Tips
- Use `max_keys` to reserve space for static navigation buttons
- Enable `show_label_background: true` in the global config if labels on full-bleed icons are hard to read
- Test your generator standalone before wiring it up: `STREAMDECK_KEY_COUNT=15 ./my-generator`
- For large data sources, pass pagination params through the command string: `"command": "scripts/albums --page 0"`
- Dynamic keys support `display` blocks — nest monitoring data inside generated keys
### Gesture timing ### Gesture timing
Configured in the web UI (Settings → Gesture Timing) or in `config.json`: Configured in the web UI (Settings → Gesture Timing) or in `config.json`:

View File

@ -3,10 +3,12 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
@ -99,6 +101,52 @@ func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, er
return output, err return output, err
} }
func execDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, keyCount int) ([]config.KeyConfig, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
if dg.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", dg.Command)
} else {
script := dg.Script
if !filepath.IsAbs(script) {
script = filepath.Join(configDir(), script)
}
cmd = exec.CommandContext(ctx, script)
}
// Set environment variables for the script
cmd.Env = append(os.Environ(),
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", configDir()),
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Log stderr separately (don't merge into stdout for JSON parsing)
if stderr.Len() > 0 {
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
}
if err != nil {
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
}
// Parse JSON from stdout
var keys []config.KeyConfig
output := stdout.String()
if err := json.Unmarshal([]byte(output), &keys); err != nil {
return nil, fmt.Errorf("parse dynamic_keys JSON: %w", err)
}
return keys, nil
}
func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error { func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error {
parts := strings.SplitN(a.Builtin, ":", 2) parts := strings.SplitN(a.Builtin, ":", 2)
if len(parts) < 1 { if len(parts) < 1 {

View File

@ -40,6 +40,19 @@
} }
] ]
}, },
{
"index": 2,
"icon": "fa:gamepad",
"label": "Games",
"font_size": 11,
"actions": [
{
"trigger": "tap",
"type": "page",
"page": "games"
}
]
},
{ {
"index": 7, "index": 7,
"icon": "fa:chevron-right", "icon": "fa:chevron-right",
@ -58,6 +71,23 @@
] ]
} }
] ]
},
{
"name": "games",
"icon": "fa:gamepad",
"keys": [
{
"index": 0,
"icon": "fa:arrow-left",
"actions": [
{ "trigger": "tap", "type": "page", "page": "main" }
]
}
],
"dynamic_keys": {
"script": "scripts/lutris-keys",
"interval": "120s"
}
} }
], ],
"auto_switch": [], "auto_switch": [],

View File

@ -184,7 +184,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if page == nil { if page == nil {
continue continue
} }
for _, k := range page.Keys { for _, k := range primaryPM.activeKeys() {
if k.Index == evt.Index { if k.Index == evt.Index {
if len(k.Actions) > 0 { if len(k.Actions) > 0 {
ge.HandleEvent(evt, k.Actions) ge.HandleEvent(evt, k.Actions)

View File

@ -43,10 +43,19 @@ type PageConfig struct {
Name string `json:"name"` Name string `json:"name"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Keys []KeyConfig `json:"keys"` Keys []KeyConfig `json:"keys"`
DynamicKeys *DynamicKeyGen `json:"dynamic_keys,omitempty"`
Background string `json:"background,omitempty"` Background string `json:"background,omitempty"`
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"` Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
} }
type DynamicKeyGen struct {
Command string `json:"command,omitempty"`
Script string `json:"script,omitempty"`
Interval string `json:"interval,omitempty"`
Timeout string `json:"timeout,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
}
type KeyConfig struct { type KeyConfig struct {
Index int `json:"index"` Index int `json:"index"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
@ -293,6 +302,35 @@ func (c *Config) Validate() error {
} }
} }
} }
if p.DynamicKeys != nil {
if p.DynamicKeys.Command == "" && p.DynamicKeys.Script == "" {
return fmt.Errorf("page %s: dynamic_keys requires command or script", p.Name)
}
if p.DynamicKeys.Command != "" && p.DynamicKeys.Script != "" {
return fmt.Errorf("page %s: dynamic_keys command and script are mutually exclusive", p.Name)
}
if p.DynamicKeys.Interval != "" {
interval, err := time.ParseDuration(p.DynamicKeys.Interval)
if err != nil {
return fmt.Errorf("page %s: invalid dynamic_keys interval %q: %w", p.Name, p.DynamicKeys.Interval, err)
}
if interval < time.Second {
return fmt.Errorf("page %s: dynamic_keys interval must be at least 1s", p.Name)
}
if interval > 24*time.Hour {
return fmt.Errorf("page %s: dynamic_keys interval must not exceed 24h", p.Name)
}
}
if p.DynamicKeys.Timeout != "" {
if _, err := time.ParseDuration(p.DynamicKeys.Timeout); err != nil {
return fmt.Errorf("page %s: invalid dynamic_keys timeout %q: %w", p.Name, p.DynamicKeys.Timeout, err)
}
}
if p.DynamicKeys.MaxKeys < 0 {
return fmt.Errorf("page %s: dynamic_keys max_keys must be >= 0", p.Name)
}
}
} }
for _, p := range c.Pages { for _, p := range c.Pages {

245
page.go
View File

@ -50,6 +50,10 @@ type PageManager struct {
displayMu sync.RWMutex displayMu sync.RWMutex
defaultFont string defaultFont string
showLabelBackground bool showLabelBackground bool
dynamicCancel context.CancelFunc
dynamicKeys []config.KeyConfig
dynamicKeysMu sync.RWMutex
dynamicResults map[string][]config.KeyConfig
} }
func NewPageManager(deck *Deck) *PageManager { func NewPageManager(deck *Deck) *PageManager {
@ -59,19 +63,88 @@ func NewPageManager(deck *Deck) *PageManager {
keyStates: make(map[int]*keyState), keyStates: make(map[int]*keyState),
displayOutputs: make(map[int]*DisplayOutput), displayOutputs: make(map[int]*DisplayOutput),
defaultFont: "medium", defaultFont: "medium",
dynamicResults: make(map[string][]config.KeyConfig),
} }
} }
func (pm *PageManager) activeKeys() []config.KeyConfig {
pm.mu.RLock()
page := pm.pages[pm.active]
pm.mu.RUnlock()
if page == nil {
return nil
}
pm.dynamicKeysMu.RLock()
dyn := pm.dynamicKeys
pm.dynamicKeysMu.RUnlock()
if dyn == nil {
return page.Keys
}
used := make(map[int]bool, len(page.Keys))
result := make([]config.KeyConfig, 0, len(page.Keys)+len(dyn))
for _, k := range page.Keys {
used[k.Index] = true
result = append(result, k)
}
for _, k := range dyn {
if !used[k.Index] {
result = append(result, k)
}
}
return result
}
// GetEffectiveKeys returns merged (static + cached dynamic) keys for a page.
// Used by the web UI to preview generated keys.
func (pm *PageManager) GetEffectiveKeys(name string) []config.KeyConfig {
pm.mu.RLock()
page := pm.pages[name]
pm.mu.RUnlock()
if page == nil {
return nil
}
pm.dynamicKeysMu.RLock()
cached := pm.dynamicResults[name]
pm.dynamicKeysMu.RUnlock()
if cached == nil {
return page.Keys
}
used := make(map[int]bool, len(page.Keys))
result := make([]config.KeyConfig, 0, len(page.Keys)+len(cached))
for _, k := range page.Keys {
used[k.Index] = true
result = append(result, k)
}
for _, k := range cached {
if !used[k.Index] {
result = append(result, k)
}
}
return result
}
func (pm *PageManager) LoadPages(pages []config.PageConfig) { func (pm *PageManager) LoadPages(pages []config.PageConfig) {
pm.mu.Lock() pm.mu.Lock()
defer pm.mu.Unlock()
pm.pages = make(map[string]*config.PageConfig, len(pages)) pm.pages = make(map[string]*config.PageConfig, len(pages))
for i := range pages { for i := range pages {
pm.pages[pages[i].Name] = &pages[i] pm.pages[pages[i].Name] = &pages[i]
} }
pm.mu.Unlock()
pm.dynamicKeysMu.Lock()
pm.dynamicResults = make(map[string][]config.KeyConfig)
pm.dynamicKeysMu.Unlock()
} }
func (pm *PageManager) ActivatePage(name string) error { func (pm *PageManager) ActivatePage(name string) error {
pm.stopDynamicKeys()
pm.mu.Lock() pm.mu.Lock()
defer pm.mu.Unlock() defer pm.mu.Unlock()
@ -117,6 +190,12 @@ func (pm *PageManager) ActivatePage(name string) error {
pm.renderKey(i, &k) pm.renderKey(i, &k)
} }
if page.DynamicKeys != nil {
go func() {
pm.startDynamicKeys(page.DynamicKeys)
}()
}
return nil return nil
} }
@ -149,13 +228,7 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
} }
func (pm *PageManager) RenderKey(keyIndex int) { func (pm *PageManager) RenderKey(keyIndex int) {
pm.mu.RLock() for _, k := range pm.activeKeys() {
defer pm.mu.RUnlock()
page := pm.pages[pm.active]
if page == nil {
return
}
for _, k := range page.Keys {
if k.Index == keyIndex { if k.Index == keyIndex {
pm.renderKey(keyIndex, &k) pm.renderKey(keyIndex, &k)
return return
@ -164,13 +237,7 @@ func (pm *PageManager) RenderKey(keyIndex int) {
} }
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
pm.mu.RLock() for _, k := range pm.activeKeys() {
page := pm.pages[pm.active]
pm.mu.RUnlock()
if page == nil {
return
}
for _, k := range page.Keys {
if k.Index == keyIndex && k.Display != nil { if k.Index == keyIndex && k.Display != nil {
pm.renderKeyOutput(keyIndex, k.Display, output, nil) pm.renderKeyOutput(keyIndex, k.Display, output, nil)
return return
@ -260,6 +327,8 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
} }
func (pm *PageManager) stopPeriodicKeys() { func (pm *PageManager) stopPeriodicKeys() {
pm.stopDynamicKeys()
pm.mu.Lock() pm.mu.Lock()
defer pm.mu.Unlock() defer pm.mu.Unlock()
for _, ks := range pm.keyStates { for _, ks := range pm.keyStates {
@ -296,11 +365,136 @@ func (pm *PageManager) startPeriodicKeys() {
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx}) keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
} }
} }
dg := page.DynamicKeys
pm.mu.Unlock() pm.mu.Unlock()
for _, dk := range keys { for _, dk := range keys {
go pm.runDisplayKey(dk.index, dk.display, dk.ctx) go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
} }
if dg != nil {
pm.startDynamicKeys(dg)
}
}
func (pm *PageManager) stopDynamicKeys() {
pm.dynamicKeysMu.Lock()
if pm.dynamicCancel != nil {
pm.dynamicCancel()
pm.dynamicCancel = nil
}
pm.dynamicKeys = nil
pm.dynamicKeysMu.Unlock()
}
func (pm *PageManager) startDynamicKeys(dg *config.DynamicKeyGen) {
ctx, cancel := context.WithCancel(context.Background())
pm.dynamicKeysMu.Lock()
if pm.dynamicCancel != nil {
pm.dynamicCancel()
}
pm.dynamicCancel = cancel
pm.dynamicKeysMu.Unlock()
numKeys := dg.MaxKeys
if numKeys <= 0 {
numKeys = pm.deck.NumKeys()
}
var interval time.Duration
if dg.Interval != "" {
interval, _ = time.ParseDuration(dg.Interval)
}
if interval == 0 {
interval = 60 * time.Second
}
go pm.runDynamicKeyGenerator(ctx, dg, numKeys, interval)
}
func (pm *PageManager) runDynamicKeyGenerator(ctx context.Context, dg *config.DynamicKeyGen, numKeys int, interval time.Duration) {
timeout := 15 * time.Second
if dg.Timeout != "" {
if t, err := time.ParseDuration(dg.Timeout); err == nil && t > 0 {
timeout = t
}
}
pm.updateDynamicKeys(dg, timeout, numKeys)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pm.updateDynamicKeys(dg, timeout, numKeys)
case <-ctx.Done():
return
}
}
}
func (pm *PageManager) updateDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, numKeys int) {
keys, err := execDynamicKeys(dg, timeout, numKeys)
if err != nil {
slog.Warn("dynamic_keys generator failed", "error", err)
return
}
if len(keys) > numKeys {
keys = keys[:numKeys]
}
pm.mu.RLock()
pageName := pm.active
pm.mu.RUnlock()
pm.dynamicKeysMu.Lock()
if pm.dynamicCancel == nil {
pm.dynamicKeysMu.Unlock()
return
}
pm.dynamicKeys = keys
pm.dynamicResults[pageName] = keys
pm.dynamicKeysMu.Unlock()
slog.Info("dynamic_keys updated", "count", len(keys), "page", pageName)
pm.rerenderActivePage()
}
func (pm *PageManager) rerenderActivePage() {
pm.mu.RLock()
bg := ""
if page := pm.pages[pm.active]; page != nil {
bg = page.Background
}
pm.mu.RUnlock()
if bg != "" {
if img, err := loadImage(bg, 0, 0); err == nil {
pm.deck.FillPanel(img)
}
}
keys := pm.activeKeys()
keyByIndex := make(map[int]config.KeyConfig, len(keys))
for _, k := range keys {
keyByIndex[k.Index] = k
}
for i := 0; i < pm.deck.NumKeys(); i++ {
k, ok := keyByIndex[i]
if !ok {
if bg == "" {
pm.deck.ClearKey(i)
}
continue
}
pm.renderKey(i, &k)
}
} }
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) { func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
@ -393,15 +587,8 @@ func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color
} }
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) { func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
pm.mu.RLock()
page := pm.pages[pm.active]
pm.mu.RUnlock()
if page == nil {
return
}
var kc *config.KeyConfig var kc *config.KeyConfig
for _, k := range page.Keys { for _, k := range pm.activeKeys() {
if k.Index == idx { if k.Index == idx {
kCopy := k kCopy := k
kc = &kCopy kc = &kCopy
@ -499,13 +686,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
} }
func (pm *PageManager) RefreshDisplayKey(idx int) { func (pm *PageManager) RefreshDisplayKey(idx int) {
pm.mu.RLock() for _, k := range pm.activeKeys() {
page := pm.pages[pm.active]
pm.mu.RUnlock()
if page == nil {
return
}
for _, k := range page.Keys {
if k.Index == idx && k.Display != nil { if k.Index == idx && k.Display != nil {
timeout := 30 * time.Second timeout := 30 * time.Second
if k.Display.Timeout != "" { if k.Display.Timeout != "" {
@ -892,9 +1073,9 @@ func loadImage(path string, targetSize int, faScale float64) (image.Image, error
displaySize = 1 displaySize = 1
} }
// Resize to displaySize // Resize to displaySize preserving aspect ratio (crop to fill)
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize { if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling)) g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor))
scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize)) scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
g.Draw(scaled, img) g.Draw(scaled, img)
img = scaled img = scaled

View File

@ -52,6 +52,7 @@ document.addEventListener('alpine:init', () => {
await this.loadConfig() await this.loadConfig()
await this.loadDecks() await this.loadDecks()
setInterval(() => this.pollDisplayOutputs(), 3000) setInterval(() => this.pollDisplayOutputs(), 3000)
setInterval(() => this.loadEffectiveKeys(), 10000)
this.connectSSE() this.connectSSE()
}, },
@ -101,6 +102,7 @@ document.addEventListener('alpine:init', () => {
const res = await fetch('/api/config') const res = await fetch('/api/config')
this.config = await res.json() this.config = await res.json()
this.pages = this.config.pages this.pages = this.config.pages
await this.loadEffectiveKeys()
if (this.pages.length > 0) { if (this.pages.length > 0) {
const saved = localStorage.getItem('sd_active_page') const saved = localStorage.getItem('sd_active_page')
if (saved && this.pages.find(p => p.name === saved)) { if (saved && this.pages.find(p => p.name === saved)) {
@ -115,6 +117,19 @@ document.addEventListener('alpine:init', () => {
} }
}, },
async loadEffectiveKeys() {
try {
const res = await fetch('/api/pages')
const pagesData = await res.json()
for (const pd of pagesData) {
const page = this.pages.find(p => p.name === pd.name)
if (page && pd.effective_keys && pd.effective_keys.length > 0) {
page.effective_keys = pd.effective_keys
}
}
} catch (_) {}
},
syncSettings() { syncSettings() {
this.settingsForm = { this.settingsForm = {
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75, brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
@ -151,10 +166,13 @@ document.addEventListener('alpine:init', () => {
get gridKeys() { get gridKeys() {
const page = this.currentPage const page = this.currentPage
if (!page) return [] if (!page) return []
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
? page.effective_keys
: (page.keys || [])
const keys = [] const keys = []
const n = this.activeDeck.num_keys const n = this.activeDeck.num_keys
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const kc = page.keys.find(k => k.index === i) const kc = sourceKeys.find(k => k.index === i)
const dout = this.displayOutputs[i] const dout = this.displayOutputs[i]
keys.push({ keys.push({
index: i, index: i,
@ -166,6 +184,7 @@ document.addEventListener('alpine:init', () => {
displayBg: dout?.background || '', displayBg: dout?.background || '',
displayText: dout?.text || '', displayText: dout?.text || '',
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout), previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
}) })
} }
return keys return keys
@ -306,6 +325,9 @@ document.addEventListener('alpine:init', () => {
const page = this.currentPage const page = this.currentPage
if (!page) return if (!page) return
const kc = page.keys.find(k => k.index === idx) const kc = page.keys.find(k => k.index === idx)
if (!kc && page.dynamic_keys && page.effective_keys?.find(k => k.index === idx)) {
return
}
this.editing = idx this.editing = idx
this.showAdvanced = false this.showAdvanced = false

View File

@ -94,8 +94,11 @@
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }" :style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
@click="switchPage(p.name); subView = null"> @click="switchPage(p.name); subView = null">
<span x-text="p.name"></span> <span x-text="p.name"></span>
<span x-show="p.dynamic_keys"
title="Dynamic keys generator"
style="color:var(--accent);font-size:11px;margin-left:4px;">⚡</span>
<span style="float:right;color:var(--text-muted);font-size:11px;" <span style="float:right;color:var(--text-muted);font-size:11px;"
x-text="p.keys.length + ' keys'"></span> x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
</button> </button>
</template> </template>
</div> </div>

25
web.go
View File

@ -339,18 +339,27 @@ func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
type pageInfo struct { type pageInfo struct {
Name string `json:"name"` Name string `json:"name"`
Keys []config.KeyConfig `json:"keys"` Keys []config.KeyConfig `json:"keys"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
DynamicKeys *config.DynamicKeyGen `json:"dynamic_keys,omitempty"`
Background string `json:"background,omitempty"`
EffectiveKeys []config.KeyConfig `json:"effective_keys,omitempty"`
} }
pages := make([]pageInfo, 0, len(s.cfg.Pages)) pages := make([]pageInfo, 0, len(s.cfg.Pages))
for _, p := range s.cfg.Pages { for _, p := range s.cfg.Pages {
pages = append(pages, pageInfo{ pi := pageInfo{
Name: p.Name, Name: p.Name,
Keys: p.Keys, Keys: p.Keys,
Icon: p.Icon, Icon: p.Icon,
}) DynamicKeys: p.DynamicKeys,
Background: p.Background,
}
if p.DynamicKeys != nil && s.pm != nil {
pi.EffectiveKeys = s.pm.GetEffectiveKeys(p.Name)
}
pages = append(pages, pi)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")