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")