From 8df8b65163c84647e599efe02894995ba88d0784 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 21 Jun 2026 21:05:32 +0700 Subject: [PATCH] Dynamic key generators: plugin system for auto-generated pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 207 ++++++++++++++++++++++++++++++++ action.go | 48 ++++++++ config.example.json | 30 +++++ daemon.go | 2 +- internal/config/config.go | 38 ++++++ page.go | 245 +++++++++++++++++++++++++++++++++----- static/app.js | 24 +++- static/index.html | 5 +- web.go | 25 ++-- 9 files changed, 581 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 56eb351..f48750d 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,213 @@ Server down! 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 11–14 for labels) | +| `icon_scale` | number | Icon scale factor (0.0–1.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 1–14) ──────────────────────→ +``` + +#### 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 Configured in the web UI (Settings → Gesture Timing) or in `config.json`: diff --git a/action.go b/action.go index 7512a45..350ff3a 100644 --- a/action.go +++ b/action.go @@ -3,10 +3,12 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "log/slog" "os" "os/exec" + "path/filepath" "strings" "time" @@ -99,6 +101,52 @@ func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, er 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 { parts := strings.SplitN(a.Builtin, ":", 2) if len(parts) < 1 { diff --git a/config.example.json b/config.example.json index 3175b03..5ed9f68 100644 --- a/config.example.json +++ b/config.example.json @@ -40,6 +40,19 @@ } ] }, + { + "index": 2, + "icon": "fa:gamepad", + "label": "Games", + "font_size": 11, + "actions": [ + { + "trigger": "tap", + "type": "page", + "page": "games" + } + ] + }, { "index": 7, "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": [], diff --git a/daemon.go b/daemon.go index 0f42bc0..9d38b5b 100644 --- a/daemon.go +++ b/daemon.go @@ -184,7 +184,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { if page == nil { continue } - for _, k := range page.Keys { + for _, k := range primaryPM.activeKeys() { if k.Index == evt.Index { if len(k.Actions) > 0 { ge.HandleEvent(evt, k.Actions) diff --git a/internal/config/config.go b/internal/config/config.go index 6314aaa..14b0d12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,10 +43,19 @@ type PageConfig struct { Name string `json:"name"` Icon string `json:"icon,omitempty"` Keys []KeyConfig `json:"keys"` + DynamicKeys *DynamicKeyGen `json:"dynamic_keys,omitempty"` Background string `json:"background,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 { Index int `json:"index"` 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 { diff --git a/page.go b/page.go index d635100..d13ba38 100644 --- a/page.go +++ b/page.go @@ -50,6 +50,10 @@ type PageManager struct { displayMu sync.RWMutex defaultFont string showLabelBackground bool + dynamicCancel context.CancelFunc + dynamicKeys []config.KeyConfig + dynamicKeysMu sync.RWMutex + dynamicResults map[string][]config.KeyConfig } func NewPageManager(deck *Deck) *PageManager { @@ -59,19 +63,88 @@ func NewPageManager(deck *Deck) *PageManager { keyStates: make(map[int]*keyState), displayOutputs: make(map[int]*DisplayOutput), 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) { pm.mu.Lock() - defer pm.mu.Unlock() pm.pages = make(map[string]*config.PageConfig, len(pages)) for i := range pages { 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 { + pm.stopDynamicKeys() + pm.mu.Lock() defer pm.mu.Unlock() @@ -117,6 +190,12 @@ func (pm *PageManager) ActivatePage(name string) error { pm.renderKey(i, &k) } + if page.DynamicKeys != nil { + go func() { + pm.startDynamicKeys(page.DynamicKeys) + }() + } + return nil } @@ -149,13 +228,7 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig { } func (pm *PageManager) RenderKey(keyIndex int) { - pm.mu.RLock() - defer pm.mu.RUnlock() - page := pm.pages[pm.active] - if page == nil { - return - } - for _, k := range page.Keys { + for _, k := range pm.activeKeys() { if k.Index == keyIndex { pm.renderKey(keyIndex, &k) return @@ -164,13 +237,7 @@ func (pm *PageManager) RenderKey(keyIndex int) { } func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { - pm.mu.RLock() - page := pm.pages[pm.active] - pm.mu.RUnlock() - if page == nil { - return - } - for _, k := range page.Keys { + for _, k := range pm.activeKeys() { if k.Index == keyIndex && k.Display != nil { pm.renderKeyOutput(keyIndex, k.Display, output, nil) return @@ -260,6 +327,8 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) { } func (pm *PageManager) stopPeriodicKeys() { + pm.stopDynamicKeys() + pm.mu.Lock() defer pm.mu.Unlock() 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}) } } + dg := page.DynamicKeys pm.mu.Unlock() for _, dk := range keys { 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) { @@ -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) { - pm.mu.RLock() - page := pm.pages[pm.active] - pm.mu.RUnlock() - if page == nil { - return - } - var kc *config.KeyConfig - for _, k := range page.Keys { + for _, k := range pm.activeKeys() { if k.Index == idx { kCopy := k kc = &kCopy @@ -499,13 +686,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str } func (pm *PageManager) RefreshDisplayKey(idx int) { - pm.mu.RLock() - page := pm.pages[pm.active] - pm.mu.RUnlock() - if page == nil { - return - } - for _, k := range page.Keys { + for _, k := range pm.activeKeys() { if k.Index == idx && k.Display != nil { timeout := 30 * time.Second if k.Display.Timeout != "" { @@ -892,9 +1073,9 @@ func loadImage(path string, targetSize int, faScale float64) (image.Image, error displaySize = 1 } - // Resize to displaySize + // Resize to displaySize preserving aspect ratio (crop to fill) 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)) g.Draw(scaled, img) img = scaled diff --git a/static/app.js b/static/app.js index 04f4b64..05e6bf2 100644 --- a/static/app.js +++ b/static/app.js @@ -52,6 +52,7 @@ document.addEventListener('alpine:init', () => { await this.loadConfig() await this.loadDecks() setInterval(() => this.pollDisplayOutputs(), 3000) + setInterval(() => this.loadEffectiveKeys(), 10000) this.connectSSE() }, @@ -101,6 +102,7 @@ document.addEventListener('alpine:init', () => { const res = await fetch('/api/config') this.config = await res.json() this.pages = this.config.pages + await this.loadEffectiveKeys() if (this.pages.length > 0) { const saved = localStorage.getItem('sd_active_page') 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() { this.settingsForm = { brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75, @@ -151,10 +166,13 @@ document.addEventListener('alpine:init', () => { get gridKeys() { const page = this.currentPage if (!page) return [] + const sourceKeys = (page.effective_keys && page.effective_keys.length > 0) + ? page.effective_keys + : (page.keys || []) const keys = [] const n = this.activeDeck.num_keys 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] keys.push({ index: i, @@ -166,6 +184,7 @@ document.addEventListener('alpine:init', () => { displayBg: dout?.background || '', displayText: dout?.text || '', previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout), + isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)), }) } return keys @@ -306,6 +325,9 @@ document.addEventListener('alpine:init', () => { const page = this.currentPage if (!page) return 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.showAdvanced = false diff --git a/static/index.html b/static/index.html index 907c504..ac7369e 100644 --- a/static/index.html +++ b/static/index.html @@ -94,8 +94,11 @@ :style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }" @click="switchPage(p.name); subView = null"> + + x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"> diff --git a/web.go b/web.go index 1ad10f6..1d388c0 100644 --- a/web.go +++ b/web.go @@ -339,18 +339,27 @@ func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) { defer s.mu.RUnlock() type pageInfo struct { - Name string `json:"name"` - Keys []config.KeyConfig `json:"keys"` - Icon string `json:"icon,omitempty"` + Name string `json:"name"` + Keys []config.KeyConfig `json:"keys"` + 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)) for _, p := range s.cfg.Pages { - pages = append(pages, pageInfo{ - Name: p.Name, - Keys: p.Keys, - Icon: p.Icon, - }) + pi := pageInfo{ + Name: p.Name, + Keys: p.Keys, + 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")