Compare commits

..
16 Commits
Author SHA1 Message Date
Maksim Totmin c9871a82c2 WIP: custom dropdown + One Dark Pro theme 2026-06-22 09:59:20 +07:00
Maksim Totmin ac175fb144 Fix keyboard input on Wayland: use ydotool instead of wtype
- Replace wtype with ydotool as primary keyboard input tool on Wayland
  (wtype had a bug sending ESC instead of correct keys to GUI apps)
- Use 'ydotool type' command for better Wine/game compatibility
- Automatically start ydotoold daemon on startup if available
- Keep wtype as fallback for backward compatibility
- Improve keyboard action support for games running in Wine
2026-06-17 22:19:54 +07:00
Maksim Totmin d51d61ddfc Fix rendering of keys with background only (no icon or label) 2026-06-17 20:28:25 +07:00
Maksim Totmin ef7d4bef8e Improve icon rendering: fix scaling and centering for emoji, FontAwesome, and SVG icons 2026-06-17 20:19:56 +07:00
Maksim Totmin 777a8109b2 Apply show_label_background to device rendering and display keys 2026-06-17 20:19:06 +07:00
Maksim Totmin c6103cd9a8 Add global show_label_background setting with UI control 2026-06-17 20:19:02 +07:00
Maksim Totmin bb86e495c0 fix: svg icons now respect icon_scale parameter 2026-06-17 15:12:17 +07:00
Maksim Totmin 75f09cea50 fix: x-if->x-show for page select to fix x-model+x-for race 2026-06-17 14:00:35 +07:00
Maksim Totmin 2fb5d2a0f0 fix: load FA locally instead of CDN; Alpine color input error via x-init+ 2026-06-17 13:53:48 +07:00
Maksim Totmin 10d4c1156b fix: imageCache key includes targetSize/faScale; Alpine color input error breaks x-if rendering 2026-06-17 13:22:05 +07:00
Maksim Totmin 271e40e9c7 fix: honor icon_scale for all icon types in grid view
- index.html: replace three conditional render paths (FA CSS icon,
  image img, label span) with a single img always routed through
  /api/render, which correctly applies icon_scale, font_size, etc.
  FA icons were previously rendered via CSS font classes, bypassing
  the backend render endpoint and ignoring icon_scale
2026-06-17 12:28:26 +07:00
Maksim Totmin 14a3cec4a3 fix: restart periodic keys on device page switch, render display output in UI grid
- daemon.go: call stopPeriodicKeys/startPeriodicKeys after page switch
  from device gesture and auto-switch (was missing, causing goroutines
  to never restart for the correct page)
- app.js: forward display output background to /api/render preview URL
- index.html: show preview image and text from display output when key
  has no configured background/icon/label
2026-06-17 11:12:00 +07:00
Maksim Totmin cb52e639f0 fix: keep active page on config save, add Cyrillic preview support, fix grid sizing
- daemon.go: save/restore active page on config reload instead of
  always activating default_page (fixes page switch on save)
- render.go: replace basicfont.Face7x13 with parseDisplayFace() for
  unicode/Cyrillic support in edit key preview
- web.go: add SSE endpoint (GET /api/events) for real-time page_changed
  events and POST /api/activate-page API
- app.js: persist active page via localStorage, listen for SSE
  page_changed events
- styles.css, index.html: fix grid sizing (width 100%, minmax(0, 1fr),
  aspect-ratio on key buttons, max-width 525px)
2026-06-17 10:48:31 +07:00
Maksim Totmin 19e3040965 Multi-action keys with tap/long-press/double-tap/hold, gesture timing, display output colors, global font, and display output JSON/color parsing
- Multi-action system: each key supports multiple actions with different triggers
  (tap, long_press, double_tap, hold_start, hold_end)
- GestureEngine: timing-based gesture detection with configurable thresholds
- New config types: KeyAction, TimingConfig, DisplayOutput
- Display output now supports JSON format (text/background/text_color) and
  first-line-as-hex-color format for dynamic key backgrounds
- Display background colors are reflected in the web UI on the grid
- renderUnicodeText/renderUnicodeTextOnImage accept custom text color (fg)
- Global font setting (medium/regular) in Settings UI
- PageManager.defaultFont used as fallback for per-key font
- Alpine.js bundled locally (no CDN dependency)
- Web UI: action tabs (Tap/Long Press/Double Tap/Hold), unified More section
  with appearance + display settings, gesture timing sliders in Settings
- Grid: subtle action type icons in top-left corner matching display indicator style
- Backward compatible: old config 'action' field auto-migrated to 'actions' array
- M+ 1m font files in assets/ for reference
- Updated config.example.json and README with all new features
2026-06-17 00:09:48 +07:00
Maksim Totmin dfe6544686 Multi-device support, dynamic grid adaptation, keyboard action
- Multi-device: OpenAllDecks(), GET /api/decks, per-deck PageManagers
- Dynamic grid: frontend adapts to connected device layout
- Keyboard action: new action type for sending key combos via wtype/xdotool
- Key recorder UI: click-based modifier toggles + single key capture
- Config: remove hardcoded maxKey validation
- Screensaver and auto-switch sync across all decks
2026-06-16 22:51:39 +07:00
Maksim Totmin 4c98a9d670 Initial commit: Stream Deck daemon with web UI
- Web-based key editor (Alpine.js SPA)
- Actions: shell commands, scripts, built-in media/volume/brightness, page switching
- Keyboard shortcut action (wtype/xdotool)
- On-device display with periodic command output
- Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11)
- Screensaver with idle detection
- Config hot-reload
- Multi-device support
2026-06-16 22:35:14 +07:00
48 changed files with 2847 additions and 3383 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
/streamdeck-lets-go streamdeck-lets-go
*.test *.test
*.out *.out
*.exe *.exe
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024-2026 Wakatron
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-33
View File
@@ -1,33 +0,0 @@
BINARY := streamdeck-lets-go
MODULE := streamdeck-lets-go
CMDDIR := cmd/streamdeck-lets-go
PKGARCH := packaging
.PHONY: all build test vet fmt clean install
all: build
build:
CGO_ENABLED=1 go build -ldflags "-s -w" -o $(BINARY) ./$(CMDDIR)
test:
go test ./...
vet:
go vet ./...
fmt:
gofmt -l .
clean:
rm -f $(BINARY)
install: build
install -Dm755 $(BINARY) /usr/local/bin/$(BINARY)
install -Dm644 $(PKGARCH)/streamdeck-lets-go.service /usr/lib/systemd/user/streamdeck-lets-go.service
install -Dm644 $(PKGARCH)/90-streamdeck.rules /usr/lib/udev/rules.d/90-streamdeck.rules
uninstall:
rm -f /usr/local/bin/$(BINARY)
rm -f /usr/lib/systemd/user/streamdeck-lets-go.service
rm -f /usr/lib/udev/rules.d/90-streamdeck.rules
+4 -235
View File
@@ -27,9 +27,7 @@
### Install from source ### Install from source
```bash ```bash
make build go build -o streamdeck-lets-go .
# or
go build -o streamdeck-lets-go ./cmd/streamdeck-lets-go
``` ```
### Run ### Run
@@ -49,19 +47,7 @@ Open `http://localhost:9090` in your browser.
--- ---
## Project structure ## Configuration
```
cmd/streamdeck-lets-go entry point (CLI: daemon, switch, discover)
internal/
config JSON config model, load/save/validate
deck Stream Deck hardware: open, render, brightness, events
render Key rendering, icon/fonts, PageManager, display outputs
web HTTP API + embedded Alpine.js SPA
daemon Event loop, autoswitch, screensaver, actions, gestures
packaging/ AUR PKGBUILD, systemd unit, udev rule
Makefile build, test, vet, fmt, install
```
The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI. The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI.
@@ -141,213 +127,6 @@ 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`:
@@ -385,9 +164,9 @@ After a configurable idle period the deck dims or shows a custom image.
## Building from source ## Building from source
```bash ```bash
git clone https://github.com/totmin/streamdeck-lets-go.git git clone git@git.totmin.ru:en2zmax/streamdeck-lets-go.git
cd streamdeck-lets-go cd streamdeck-lets-go
make build go build -o streamdeck-lets-go .
``` ```
Dependencies: Dependencies:
@@ -412,16 +191,6 @@ Dependencies:
--- ---
## Credits
This project builds on the work of the following open-source libraries:
- **[dh1tw/streamdeck](https://github.com/dh1tw/streamdeck)** (MIT) — Go protocol driver for Elgato Stream Deck devices
- **[bearsh/hid](https://github.com/bearsh/hid)** (MIT) — Go USB HID bindings via CGO / libusb
- **[disintegration/gift](https://github.com/disintegration/gift)** (MIT) — Go Image Filtering Toolkit
---
## License ## License
MIT MIT
+45 -9
View File
@@ -1,18 +1,19 @@
package daemon package main
import ( import (
"bytes"
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
) )
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error { func ExecuteAction(a *config.Action, deck *Deck, pm *PageManager) error {
if a == nil { if a == nil {
return nil return nil
} }
@@ -73,7 +74,32 @@ func execScript(a *config.Action) error {
return nil return nil
} }
func execBuiltin(a *config.Action, deck *deck.Deck, pm *render.PageManager) error { func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
if d.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
} else {
cmd = exec.CommandContext(ctx, d.Script)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return output, err
}
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 {
return fmt.Errorf("invalid builtin: %s", a.Builtin) return fmt.Errorf("invalid builtin: %s", a.Builtin)
@@ -151,10 +177,10 @@ func brightnessAction(action string) error {
return exec.Command("sh", "-c", cmd).Run() return exec.Command("sh", "-c", cmd).Run()
} }
func pageBuiltinAction(action string, pm *render.PageManager) error { func pageBuiltinAction(action string, pm *PageManager) error {
switch action { switch action {
case "next": case "next":
pageNames := pm.GetPageNames() pageNames := pm.PageNames()
current := pm.ActivePageName() current := pm.ActivePageName()
for i, name := range pageNames { for i, name := range pageNames {
if name == current { if name == current {
@@ -166,7 +192,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
return pm.ActivatePage(pageNames[0]) return pm.ActivatePage(pageNames[0])
} }
case "prev": case "prev":
pageNames := pm.GetPageNames() pageNames := pm.PageNames()
current := pm.ActivePageName() current := pm.ActivePageName()
for i, name := range pageNames { for i, name := range pageNames {
if name == current { if name == current {
@@ -183,7 +209,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
return nil return nil
} }
func deckBuiltinAction(action string, deck *deck.Deck) error { func deckBuiltinAction(action string, deck *Deck) error {
switch action { switch action {
case "brightness-up": case "brightness-up":
current := deck.Brightness() current := deck.Brightness()
@@ -286,3 +312,13 @@ func execYDOTool(keys string) error {
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }
func (pm *PageManager) PageNames() []string {
pm.mu.RLock()
defer pm.mu.RUnlock()
names := make([]string, 0, len(pm.pages))
for name := range pm.pages {
names = append(names, name)
}
return names
}
@@ -1,4 +1,4 @@
package daemon package main
import ( import (
"context" "context"
@@ -9,7 +9,6 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
@@ -265,7 +264,6 @@ func (d *niriDetector) getWindow(ctx context.Context) Window {
} }
type gnomeDetector struct{ basePoll } type gnomeDetector struct{ basePoll }
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) { func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx) ctx, d.cancel = context.WithCancel(ctx)
@@ -308,7 +306,6 @@ func parseGnomeEval(out string) string {
} }
type kdeDetector struct{ basePoll } type kdeDetector struct{ basePoll }
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) { func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx) ctx, d.cancel = context.WithCancel(ctx)
@@ -325,7 +322,6 @@ func (d *kdeDetector) getWindow(ctx context.Context) Window {
} }
type x11Detector struct{ basePoll } type x11Detector struct{ basePoll }
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) { func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx) ctx, d.cancel = context.WithCancel(ctx)
@@ -342,7 +338,6 @@ func (d *x11Detector) getWindow(ctx context.Context) Window {
} }
type portalDetector struct{ basePoll } type portalDetector struct{ basePoll }
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) { func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx) ctx, d.cancel = context.WithCancel(ctx)
@@ -362,7 +357,6 @@ type AutoSwitchManager struct {
lastManualPage string lastManualPage string
autoPage string autoPage string
autoStay bool autoStay bool
paused atomic.Bool // disables auto-switch when device is disconnected
} }
type compiledRule struct { type compiledRule struct {
@@ -416,25 +410,6 @@ func (m *AutoSwitchManager) NotifyManualPage(name string) {
slog.Debug("auto-switch: manual page set", "page", name) slog.Debug("auto-switch: manual page set", "page", name)
} }
// Pause disables auto-switch evaluation — use when the device is disconnected
// to prevent unnecessary page switches on a closed deck.
func (m *AutoSwitchManager) Pause() {
m.paused.Store(true)
slog.Debug("auto-switch: paused")
}
// Resume re-enables auto-switch evaluation after reconnect.
// Stale window events should be drained from the channel before resuming.
func (m *AutoSwitchManager) Resume() {
m.paused.Store(false)
slog.Debug("auto-switch: resumed")
}
// IsPaused returns true when auto-switch evaluation is paused.
func (m *AutoSwitchManager) IsPaused() bool {
return m.paused.Load()
}
func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) { func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1,4 +1,4 @@
package daemon package main
import ( import (
"encoding/json" "encoding/json"
+4 -4
View File
@@ -1,4 +1,4 @@
package render package main
import ( import (
"bytes" "bytes"
@@ -16,9 +16,9 @@ import (
) )
type cbdtIndex struct { type cbdtIndex struct {
once sync.Once once sync.Once
imgs []image.Image imgs []image.Image
scale int scale int
} }
var emojiCBDT cbdtIndex var emojiCBDT cbdtIndex
-30
View File
@@ -40,19 +40,6 @@
} }
] ]
}, },
{
"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",
@@ -71,23 +58,6 @@
] ]
} }
] ]
},
{
"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": [],
+39 -93
View File
@@ -1,4 +1,4 @@
package daemon package main
import ( import (
"context" "context"
@@ -8,9 +8,6 @@ import (
"time" "time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
"streamdeck-lets-go/internal/web"
) )
type RunOptions struct { type RunOptions struct {
@@ -18,11 +15,10 @@ type RunOptions struct {
HTTPAddr string HTTPAddr string
HTTPEnabled bool HTTPEnabled bool
NoDeck bool NoDeck bool
StartPage string
} }
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web := web.NewWebServer(cfg, opts.ConfigPath) web := NewWebServer(cfg, opts.ConfigPath)
if !opts.NoDeck || opts.HTTPEnabled { if !opts.NoDeck || opts.HTTPEnabled {
checkKeyboardTool() checkKeyboardTool()
@@ -41,11 +37,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
return nil return nil
} }
var decks []*deck.Deck var decks []*Deck
var err error var err error
for { for {
decks, err = deck.OpenAllDecks() decks, err = OpenAllDecks()
if err == nil { if err == nil {
break break
} }
@@ -57,26 +53,16 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
} }
} }
pageMgrs := make([]*render.PageManager, len(decks)) pageMgrs := make([]*PageManager, len(decks))
for i, d := range decks { for i, d := range decks {
pageMgrs[i] = render.NewPageManager(d) pageMgrs[i] = NewPageManager(d)
pageMgrs[i].DefaultFont = cfg.Font pageMgrs[i].defaultFont = cfg.Font
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground pageMgrs[i].showLabelBackground = cfg.ShowLabelBackground
pageMgrs[i].LoadPages(cfg.Pages) pageMgrs[i].LoadPages(cfg.Pages)
} }
primaryPM := pageMgrs[0] primaryPM := pageMgrs[0]
primaryDeck := decks[0] primaryDeck := decks[0]
startPage := opts.StartPage
if startPage == "" {
startPage = cfg.DefaultPage
}
if !pageExists(cfg.Pages, startPage) {
slog.Warn("start page not found in pages, falling back to default",
"requested", startPage, "default", cfg.DefaultPage)
startPage = cfg.DefaultPage
}
defer func() { defer func() {
for _, d := range decks { for _, d := range decks {
d.Close() d.Close()
@@ -92,10 +78,10 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web.SetExtraPageManagers(pageMgrs[1:]) web.SetExtraPageManagers(pageMgrs[1:])
for _, pm := range pageMgrs { for _, pm := range pageMgrs {
if err := pm.ActivatePage(startPage); err != nil { if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
slog.Warn("activate start page", "error", err) slog.Warn("activate default page", "error", err)
} }
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
var windowCh <-chan Window var windowCh <-chan Window
@@ -112,7 +98,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
windowCh, _ = detector.Start(ctx) windowCh, _ = detector.Start(ctx)
} }
asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage) asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
ssCtrl := NewScreensaver(&cfg.Screensaver) ssCtrl := NewScreensaver(&cfg.Screensaver)
@@ -130,8 +116,8 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
asm.NotifyManualPage(a.Page) asm.NotifyManualPage(a.Page)
} }
for _, pm := range pageMgrs { for _, pm := range pageMgrs {
pm.StopPeriodicKeys() pm.stopPeriodicKeys()
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
web.BroadcastPageChange(newPage) web.BroadcastPageChange(newPage)
} else { } else {
@@ -139,7 +125,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
} }
}) })
reconnectTicker := time.NewTicker(2 * time.Second) reconnectTicker := time.NewTicker(5 * time.Second)
defer reconnectTicker.Stop() defer reconnectTicker.Stop()
ssTicker := time.NewTicker(5 * time.Second) ssTicker := time.NewTicker(5 * time.Second)
@@ -157,34 +143,22 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
case evt, ok := <-primaryDeck.Events(): case evt, ok := <-primaryDeck.Events():
if !ok { if !ok {
slog.Warn("deck event channel closed, attempting reconnect...") slog.Warn("deck event channel closed, attempting reconnect...")
// Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys()
asm.Pause()
primaryDeck.Close() primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage) newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if newDeck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
// Update all references to the new device and page manager.
pageMgrs[0] = primaryPM
decks[0] = newDeck decks[0] = newDeck
primaryDeck = newDeck primaryDeck = newDeck
web.SetDecks(decks) web.SetDecks(decks)
web.SetPageManager(primaryPM)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
asm.NotifyManualPage(startPage)
asm.Resume()
continue continue
} }
wasSsActive := ssCtrl.IsActive() wasSsActive := ssCtrl.IsActive()
ssCtrl.NotifyInput() ssCtrl.NotifyInput()
if evt.Kind == deck.EventKeyPressed { if evt.Kind == EventKeyPressed {
if wasSsActive { if wasSsActive {
ssCtrl.Deactivate(primaryDeck) ssCtrl.Deactivate(primaryDeck)
@@ -210,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 primaryPM.ActiveKeys() { for _, k := range page.Keys {
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)
@@ -220,20 +194,13 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
} }
case win := <-windowCh: case win := <-windowCh:
if asm.IsPaused() {
// Drain buffered window events accumulated while disconnected.
for len(windowCh) > 0 {
<-windowCh
}
continue
}
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok { if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
for _, pm := range pageMgrs { for _, pm := range pageMgrs {
if err := pm.ActivatePage(page); err != nil { if err := pm.ActivatePage(page); err != nil {
slog.Warn("auto-switch: activate page", "error", err) slog.Warn("auto-switch: activate page", "error", err)
} }
pm.StopPeriodicKeys() pm.stopPeriodicKeys()
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
web.BroadcastPageChange(page) web.BroadcastPageChange(page)
} }
@@ -259,14 +226,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
for _, d := range decks { for _, d := range decks {
d.SetBrightness(deviceBrightness(cfg, d.Serial())) d.SetBrightness(deviceBrightness(cfg, d.Serial()))
} }
var activePages []string var activePages []string
for _, pm := range pageMgrs { for _, pm := range pageMgrs {
activePages = append(activePages, pm.ActivePageName()) activePages = append(activePages, pm.ActivePageName())
pm.StopPeriodicKeys() pm.stopPeriodicKeys()
pm.DefaultFont = cfg.Font pm.defaultFont = cfg.Font
pm.ShowLabelBackground = cfg.ShowLabelBackground pm.showLabelBackground = cfg.ShowLabelBackground
pm.LoadPages(cfg.Pages) pm.LoadPages(cfg.Pages)
} }
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs) ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
asm.Reload(cfg.AutoSwitch) asm.Reload(cfg.AutoSwitch)
if len(cfg.AutoSwitch) > 0 && detector == nil { if len(cfg.AutoSwitch) > 0 && detector == nil {
@@ -286,7 +253,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if err := pm.ActivatePage(page); err != nil { if err := pm.ActivatePage(page); err != nil {
slog.Warn("reload: activate page", "error", err) slog.Warn("reload: activate page", "error", err)
} }
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
} }
if lastConfigMod.IsZero() { if lastConfigMod.IsZero() {
@@ -296,27 +263,15 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
case <-reconnectTicker.C: case <-reconnectTicker.C:
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil { if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
slog.Warn("deck connection lost, reconnecting...", "error", err) slog.Warn("deck connection lost, reconnecting...", "error", err)
// Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys()
asm.Pause()
primaryDeck.Close() primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage) newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if newDeck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
// Update all references to the new device and page manager.
pageMgrs[0] = primaryPM
decks[0] = newDeck decks[0] = newDeck
primaryDeck = newDeck primaryDeck = newDeck
web.SetDecks(decks) web.SetDecks(decks)
web.SetPageManager(primaryPM)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
asm.NotifyManualPage(startPage)
asm.Resume()
} }
case <-ssTicker.C: case <-ssTicker.C:
@@ -367,32 +322,23 @@ func deviceBrightness(cfg *config.Config, serial string) int {
return 75 return 75
} }
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck { func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck {
for { for {
newDeck, err := deck.OpenDeck("") newDeck, err := OpenDeck("")
if err == nil { if err == nil {
*pm = render.NewPageManager(newDeck) *pm = NewPageManager(newDeck)
(*pm).DefaultFont = cfg.Font (*pm).defaultFont = cfg.Font
(*pm).ShowLabelBackground = cfg.ShowLabelBackground (*pm).showLabelBackground = cfg.ShowLabelBackground
(*pm).LoadPages(cfg.Pages) (*pm).LoadPages(cfg.Pages)
(*pm).ActivatePage(startPage) (*pm).ActivatePage(cfg.DefaultPage)
(*pm).StartPeriodicKeys() (*pm).startPeriodicKeys()
asm.NotifyManualPage(cfg.DefaultPage)
return newDeck return newDeck
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
case <-time.After(1 * time.Second): case <-time.After(3 * time.Second):
} }
} }
} }
// pageExists reports whether pages contains a page with the given name.
func pageExists(pages []config.PageConfig, name string) bool {
for _, p := range pages {
if p.Name == name {
return true
}
}
return false
}
+23 -23
View File
@@ -1,4 +1,4 @@
package deck package main
import ( import (
"fmt" "fmt"
@@ -22,28 +22,28 @@ type DeviceInfo struct {
} }
type DeckConfig struct { type DeckConfig struct {
PID uint16 PID uint16
Name string Name string
KeysX int KeysX int
KeysY int KeysY int
KeySize int KeySize int
ImageFmt string ImageFmt string
Rotate bool Rotate bool
Convert bool Convert bool
HasDials bool HasDials bool
HasTouch bool HasTouch bool
} }
var KnownDecks = []DeckConfig{ var knownDecks = []DeckConfig{
{PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true}, {PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true},
{PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true}, {PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
{PID: 0x63, Name: "OriginalV2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"}, {PID: 0x63, Name: "OriginalV2",KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
{PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true}, {PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
{PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"}, {PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
} }
func findConfig(pid uint16) (DeckConfig, bool) { func findConfig(pid uint16) (DeckConfig, bool) {
for _, d := range KnownDecks { for _, d := range knownDecks {
if d.PID == pid { if d.PID == pid {
return d, true return d, true
} }
@@ -161,12 +161,12 @@ func (d *Deck) Close() {
d.sd.Close() d.sd.Close()
} }
func (d *Deck) Serial() string { return d.serial } func (d *Deck) Serial() string { return d.serial }
func (d *Deck) Events() <-chan Event { return d.events } func (d *Deck) Events() <-chan Event { return d.events }
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY } func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
func (d *Deck) KeySize() int { return d.cfg.KeySize } func (d *Deck) KeySize() int { return d.cfg.KeySize }
func (d *Deck) Config() DeckConfig { return d.cfg } func (d *Deck) Config() DeckConfig { return d.cfg }
func (d *Deck) Model() string { return d.cfg.Name } func (d *Deck) Model() string { return d.cfg.Name }
type DeckInfo struct { type DeckInfo struct {
Serial string `json:"serial"` Serial string `json:"serial"`
+209
View File
@@ -0,0 +1,209 @@
package main
import "sync"
var emojiShortcodes = map[string]rune{
// media
"play_pause": 0x25B6,
"stop": 0x23F9,
"record": 0x23FA,
"eject": 0x23CF,
"track_previous": 0x23EE,
"track_next": 0x23ED,
"fast_forward": 0x23E9,
"rewind": 0x23EA,
"shuffle": 0x1F500,
"repeat": 0x1F501,
"repeat_one": 0x1F502,
// volume
"speaker": 0x1F50A,
"mute": 0x1F507,
"sound": 0x1F509,
// navigation
"arrow_up": 0x2B06,
"arrow_down": 0x2B07,
"arrow_left": 0x2B05,
"arrow_right": 0x27A1,
"arrows_clockwise": 0x1F503,
"arrows_counterclockwise": 0x1F504,
// status
"check": 0x2705,
"heavy_check_mark": 0x2714,
"x": 0x274C,
"heavy_multiplication_x": 0x2716,
"warning": 0x26A0,
"information_source": 0x2139,
"question": 0x2753,
"exclamation": 0x2757,
"white_check_mark": 0x2705,
"heavy_plus_sign": 0x2795,
"heavy_minus_sign": 0x2796,
"heavy_division_sign": 0x2797,
// actions
"gear": 0x2699,
"hammer": 0x1F528,
"wrench": 0x1F527,
"key": 0x1F511,
"lock": 0x1F512,
"unlocked": 0x1F513,
"magnifying_glass": 0x1F50D,
"home": 0x1F3E0,
"bookmark": 0x1F516,
"bell": 0x1F514,
"clock": 0x1F550,
"alarm_clock": 0x23F0,
"hourglass": 0x231B,
"calendar": 0x1F4C5,
"envelope": 0x2709,
"camera": 0x1F4F7,
"video_camera": 0x1F4F9,
"microphone": 0x1F3A4,
"telephone": 0x260E,
"phone": 0x1F4DE,
"computer": 0x1F4BB,
"laptop": 0x1F4BB,
"folder": 0x1F4C1,
"open_file_folder": 0x1F4C2,
"clipboard": 0x1F4CB,
"memo": 0x1F4DD,
"pencil": 0x270F,
"scissors": 0x2702,
"link": 0x1F517,
"paperclip": 0x1F4CE,
"pushpin": 0x1F4CC,
"trash": 0x1F5D1,
"star": 0x2B50,
"trophy": 0x1F3C6,
"medal": 0x1F3C5,
"target": 0x1F3AF,
"dart": 0x1F3AF,
// objects
"lightbulb": 0x1F4A1,
"bulb": 0x1F4A1,
"battery": 0x1F50B,
"electric_plug": 0x1F50C,
"rocket": 0x1F680,
"airplane": 0x2708,
"car": 0x1F697,
"bicycle": 0x1F6B2,
"headphones": 0x1F3A7,
"gamepad": 0x1F3AE,
"joystick": 0x1F579,
"musical_note": 0x1F3B5,
"notes": 0x1F3B6,
"printer": 0x1F5A8,
"keyboard": 0x2328,
// weather
"sun": 0x2600,
"sunny": 0x2600,
"moon": 0x1F319,
"cloud": 0x2601,
"rainbow": 0x1F308,
"fire": 0x1F525,
"flame": 0x1F525,
"zap": 0x26A1,
"lightning": 0x26A1,
"snowflake": 0x2744,
"umbrella": 0x2602,
// hearts
"heart": 0x2764,
"yellow_heart": 0x1F49B,
"green_heart": 0x1F49A,
"blue_heart": 0x1F499,
"purple_heart": 0x1F49C,
"black_heart": 0x1F5A4,
"broken_heart": 0x1F494,
"two_hearts": 0x1F495,
"sparkling_heart": 0x1F496,
"heartpulse": 0x1F497,
"heart_beat": 0x1F493,
"revolving_hearts": 0x1F49E,
"cupid": 0x1F498,
"gift_heart": 0x1F49D,
// faces
"smile": 0x1F600,
"smiley": 0x1F603,
"grinning": 0x1F604,
"blush": 0x1F60A,
"wink": 0x1F609,
"heart_eyes": 0x1F60D,
"kissing_heart": 0x1F618,
"kissing": 0x1F617,
"smirk": 0x1F60F,
"stuck_out_tongue": 0x1F61B,
"stuck_out_tongue_winking_eye": 0x1F61C,
"sunglasses": 0x1F60E,
"innocent": 0x1F607,
"neutral_face": 0x1F610,
"expressionless": 0x1F611,
"thinking": 0x1F914,
"confused": 0x1F615,
"worried": 0x1F61F,
"frown": 0x1F641,
"persevere": 0x1F623,
"tired": 0x1F62B,
"weary": 0x1F629,
"cry": 0x1F622,
"sob": 0x1F62D,
"sweat_smile": 0x1F605,
"joy": 0x1F602,
"relaxed": 0x263A,
"angry": 0x1F620,
"rage": 0x1F621,
"skull": 0x1F480,
"ghost": 0x1F47B,
"robot": 0x1F916,
"sleeping": 0x1F634,
"sleep": 0x1F634,
"zzz": 0x1F4A4,
"dizzy": 0x1F4AB,
"boom": 0x1F4A5,
"collision": 0x1F4A5,
"sweat_drops": 0x1F4A6,
"dash": 0x1F4A8,
"alien": 0x1F47D,
"poop": 0x1F4A9,
// hands & gestures
"thumbsup": 0x1F44D,
"thumbsdown": 0x1F44E,
"ok_hand": 0x1F44C,
"wave": 0x1F44B,
"clap": 0x1F44F,
"open_hands": 0x1F450,
"raised_hands": 0x1F64C,
"pray": 0x1F64F,
"muscle": 0x1F4AA,
"point_up": 0x261D,
"point_down": 0x1F447,
"point_left": 0x1F448,
"point_right": 0x1F449,
"fist": 0x270A,
"raised_hand": 0x270B,
"v": 0x270C,
"victory": 0x270C,
"crossed_fingers": 0x1F91E,
"writing_hand": 0x270D,
"call_me": 0x1F919,
"hand": 0x270B,
}
var (
emojiColorFontOnce sync.Once
emojiColorFontData []byte
)
func loadColorEmojiFont() []byte {
emojiColorFontOnce.Do(func() {
emojiColorFontData = fcRead("emoji")
})
return emojiColorFontData
}
@@ -1,4 +1,4 @@
package render package main
import ( import (
"fmt" "fmt"
@@ -138,3 +138,4 @@ func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.I
func isFAIconRef(path string) bool { func isFAIconRef(path string) bool {
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:") return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
} }
@@ -1,4 +1,4 @@
package render package main
import ( import (
"embed" "embed"
@@ -14,7 +14,7 @@ var faFonts embed.FS
type faStyle int type faStyle int
const ( const (
faSolid faStyle = iota faSolid faStyle = iota
faRegular faRegular
faBrands faBrands
) )
+1979
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -1,11 +1,10 @@
package daemon package main
import ( import (
"sync" "sync"
"time" "time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
) )
type ActionCallback func(idx int, a *config.Action) type ActionCallback func(idx int, a *config.Action)
@@ -96,7 +95,7 @@ func findAction(actions []config.KeyAction, trigger string) *config.Action {
return nil return nil
} }
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) { func (ge *GestureEngine) HandleEvent(evt Event, actions []config.KeyAction) {
state := ge.getState(evt.Index) state := ge.getState(evt.Index)
if len(actions) == 0 { if len(actions) == 0 {
@@ -104,14 +103,14 @@ func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction)
} }
switch evt.Kind { switch evt.Kind {
case deck.EventKeyPressed: case EventKeyPressed:
ge.handleKeyPress(state, evt, actions) ge.handleKeyPress(state, evt, actions)
case deck.EventKeyReleased: case EventKeyReleased:
ge.handleKeyRelease(state, evt, actions) ge.handleKeyRelease(state, evt, actions)
} }
} }
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) { func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt Event, actions []config.KeyAction) {
state.mu.Lock() state.mu.Lock()
state.cancelTimers() state.cancelTimers()
state.holdActive = false state.holdActive = false
@@ -131,7 +130,7 @@ func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event,
state.mu.Unlock() state.mu.Unlock()
} }
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) { func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt Event, actions []config.KeyAction) {
state.mu.Lock() state.mu.Lock()
state.cancelTimers() state.cancelTimers()
+1 -1
View File
@@ -1,4 +1,4 @@
package render package main
import ( import (
"bytes" "bytes"
+19 -72
View File
@@ -11,16 +11,16 @@ import (
) )
type Config struct { type Config struct {
Version int `json:"version"` Version int `json:"version"`
LogLevel string `json:"log_level"` LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"` DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"` Font string `json:"font,omitempty"`
ShowLabelBackground bool `json:"show_label_background"` ShowLabelBackground bool `json:"show_label_background"`
Devices []DeviceConfig `json:"devices"` Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"` Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"` AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"` Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"` Timing TimingConfig `json:"timing,omitempty"`
} }
type TimingConfig struct { type TimingConfig struct {
@@ -43,29 +43,20 @@ 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"`
Background string `json:"background,omitempty"` Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"` Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"` FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"` IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"` Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"` Display *DisplayCfg `json:"display,omitempty"`
} }
type KeyAction struct { type KeyAction struct {
@@ -151,21 +142,6 @@ func ConfigPath(path string) string {
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json") return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
} }
var cachedConfigDir string
func ConfigDir() string {
if cachedConfigDir != "" {
return cachedConfigDir
}
home, err := os.UserHomeDir()
if err != nil {
cachedConfigDir = "."
return cachedConfigDir
}
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
return cachedConfigDir
}
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
path = ConfigPath(path) path = ConfigPath(path)
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -317,35 +293,6 @@ 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 {
-209
View File
@@ -1,209 +0,0 @@
package render
import "sync"
var emojiShortcodes = map[string]rune{
// media
"play_pause": 0x25B6,
"stop": 0x23F9,
"record": 0x23FA,
"eject": 0x23CF,
"track_previous": 0x23EE,
"track_next": 0x23ED,
"fast_forward": 0x23E9,
"rewind": 0x23EA,
"shuffle": 0x1F500,
"repeat": 0x1F501,
"repeat_one": 0x1F502,
// volume
"speaker": 0x1F50A,
"mute": 0x1F507,
"sound": 0x1F509,
// navigation
"arrow_up": 0x2B06,
"arrow_down": 0x2B07,
"arrow_left": 0x2B05,
"arrow_right": 0x27A1,
"arrows_clockwise": 0x1F503,
"arrows_counterclockwise": 0x1F504,
// status
"check": 0x2705,
"heavy_check_mark": 0x2714,
"x": 0x274C,
"heavy_multiplication_x": 0x2716,
"warning": 0x26A0,
"information_source": 0x2139,
"question": 0x2753,
"exclamation": 0x2757,
"white_check_mark": 0x2705,
"heavy_plus_sign": 0x2795,
"heavy_minus_sign": 0x2796,
"heavy_division_sign": 0x2797,
// actions
"gear": 0x2699,
"hammer": 0x1F528,
"wrench": 0x1F527,
"key": 0x1F511,
"lock": 0x1F512,
"unlocked": 0x1F513,
"magnifying_glass": 0x1F50D,
"home": 0x1F3E0,
"bookmark": 0x1F516,
"bell": 0x1F514,
"clock": 0x1F550,
"alarm_clock": 0x23F0,
"hourglass": 0x231B,
"calendar": 0x1F4C5,
"envelope": 0x2709,
"camera": 0x1F4F7,
"video_camera": 0x1F4F9,
"microphone": 0x1F3A4,
"telephone": 0x260E,
"phone": 0x1F4DE,
"computer": 0x1F4BB,
"laptop": 0x1F4BB,
"folder": 0x1F4C1,
"open_file_folder": 0x1F4C2,
"clipboard": 0x1F4CB,
"memo": 0x1F4DD,
"pencil": 0x270F,
"scissors": 0x2702,
"link": 0x1F517,
"paperclip": 0x1F4CE,
"pushpin": 0x1F4CC,
"trash": 0x1F5D1,
"star": 0x2B50,
"trophy": 0x1F3C6,
"medal": 0x1F3C5,
"target": 0x1F3AF,
"dart": 0x1F3AF,
// objects
"lightbulb": 0x1F4A1,
"bulb": 0x1F4A1,
"battery": 0x1F50B,
"electric_plug": 0x1F50C,
"rocket": 0x1F680,
"airplane": 0x2708,
"car": 0x1F697,
"bicycle": 0x1F6B2,
"headphones": 0x1F3A7,
"gamepad": 0x1F3AE,
"joystick": 0x1F579,
"musical_note": 0x1F3B5,
"notes": 0x1F3B6,
"printer": 0x1F5A8,
"keyboard": 0x2328,
// weather
"sun": 0x2600,
"sunny": 0x2600,
"moon": 0x1F319,
"cloud": 0x2601,
"rainbow": 0x1F308,
"fire": 0x1F525,
"flame": 0x1F525,
"zap": 0x26A1,
"lightning": 0x26A1,
"snowflake": 0x2744,
"umbrella": 0x2602,
// hearts
"heart": 0x2764,
"yellow_heart": 0x1F49B,
"green_heart": 0x1F49A,
"blue_heart": 0x1F499,
"purple_heart": 0x1F49C,
"black_heart": 0x1F5A4,
"broken_heart": 0x1F494,
"two_hearts": 0x1F495,
"sparkling_heart": 0x1F496,
"heartpulse": 0x1F497,
"heart_beat": 0x1F493,
"revolving_hearts": 0x1F49E,
"cupid": 0x1F498,
"gift_heart": 0x1F49D,
// faces
"smile": 0x1F600,
"smiley": 0x1F603,
"grinning": 0x1F604,
"blush": 0x1F60A,
"wink": 0x1F609,
"heart_eyes": 0x1F60D,
"kissing_heart": 0x1F618,
"kissing": 0x1F617,
"smirk": 0x1F60F,
"stuck_out_tongue": 0x1F61B,
"stuck_out_tongue_winking_eye": 0x1F61C,
"sunglasses": 0x1F60E,
"innocent": 0x1F607,
"neutral_face": 0x1F610,
"expressionless": 0x1F611,
"thinking": 0x1F914,
"confused": 0x1F615,
"worried": 0x1F61F,
"frown": 0x1F641,
"persevere": 0x1F623,
"tired": 0x1F62B,
"weary": 0x1F629,
"cry": 0x1F622,
"sob": 0x1F62D,
"sweat_smile": 0x1F605,
"joy": 0x1F602,
"relaxed": 0x263A,
"angry": 0x1F620,
"rage": 0x1F621,
"skull": 0x1F480,
"ghost": 0x1F47B,
"robot": 0x1F916,
"sleeping": 0x1F634,
"sleep": 0x1F634,
"zzz": 0x1F4A4,
"dizzy": 0x1F4AB,
"boom": 0x1F4A5,
"collision": 0x1F4A5,
"sweat_drops": 0x1F4A6,
"dash": 0x1F4A8,
"alien": 0x1F47D,
"poop": 0x1F4A9,
// hands & gestures
"thumbsup": 0x1F44D,
"thumbsdown": 0x1F44E,
"ok_hand": 0x1F44C,
"wave": 0x1F44B,
"clap": 0x1F44F,
"open_hands": 0x1F450,
"raised_hands": 0x1F64C,
"pray": 0x1F64F,
"muscle": 0x1F4AA,
"point_up": 0x261D,
"point_down": 0x1F447,
"point_left": 0x1F448,
"point_right": 0x1F449,
"fist": 0x270A,
"raised_hand": 0x270B,
"v": 0x270C,
"victory": 0x270C,
"crossed_fingers": 0x1F91E,
"writing_hand": 0x270D,
"call_me": 0x1F919,
"hand": 0x270B,
}
var (
emojiColorFontOnce sync.Once
emojiColorFontData []byte
)
func loadColorEmojiFont() []byte {
emojiColorFontOnce.Do(func() {
emojiColorFontData = fcRead("emoji")
})
return emojiColorFontData
}
File diff suppressed because it is too large Load Diff
+8 -51
View File
@@ -1,16 +1,12 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
_ "image/gif" _ "image/gif"
@@ -18,8 +14,6 @@ import (
_ "image/png" _ "image/png"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/daemon"
"streamdeck-lets-go/internal/deck"
) )
func main() { func main() {
@@ -28,16 +22,15 @@ func main() {
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError) fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)") configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
httpAddr := fs.String("addr", ":9090", "web UI listen address") httpAddr := fs.String("addr", ":9090", "web UI listen address")
startPage := fs.String("page", "", "page to activate on startup (default: config's default_page)")
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)") noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
verbose := fs.Bool("v", false, "verbose output") verbose := fs.Bool("v", false, "verbose output")
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-help" || os.Args[1] == "-h" { if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n") fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n") fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
fmt.Fprintf(os.Stderr, " switch switch running daemon to a page (e.g. 'switch games')\n") fmt.Fprintf(os.Stderr, " serve run web UI only (no deck hardware)\n")
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n") fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
fmt.Fprintf(os.Stderr, "\nFlags:\n") fmt.Fprintf(os.Stderr, "\nFlags:\n")
fs.PrintDefaults() fs.PrintDefaults()
os.Exit(0) os.Exit(0)
@@ -49,26 +42,13 @@ func main() {
switch cmd { switch cmd {
case "daemon": case "daemon":
fs.Parse(args) fs.Parse(args)
case "switch": case "serve":
fs.Parse(args) fs.Parse(args)
if fs.NArg() == 0 { *noDeck = true
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go switch <page>\n")
os.Exit(1)
}
if err := switchToPage(*httpAddr, fs.Arg(0)); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("switched to page %q\n", fs.Arg(0))
return
case "discover": case "discover":
discover() discover()
return return
default: default:
if cmd == "serve" {
fmt.Fprintf(os.Stderr, "streamdeck-lets-go: 'serve' removed; use 'daemon --no-deck' instead\n")
os.Exit(1)
}
fs.Parse(os.Args[1:]) fs.Parse(os.Args[1:])
} }
@@ -86,12 +66,11 @@ func main() {
defer cancel() defer cancel()
httpEnabled := true httpEnabled := true
if err := daemon.Run(ctx, cfg, daemon.RunOptions{ if err := Run(ctx, cfg, RunOptions{
ConfigPath: *configPath, ConfigPath: *configPath,
HTTPAddr: *httpAddr, HTTPAddr: *httpAddr,
HTTPEnabled: httpEnabled, HTTPEnabled: httpEnabled,
NoDeck: *noDeck, NoDeck: *noDeck,
StartPage: *startPage,
}); err != nil { }); err != nil {
slog.Error("run", "error", err) slog.Error("run", "error", err)
os.Exit(1) os.Exit(1)
@@ -99,7 +78,7 @@ func main() {
} }
func discover() { func discover() {
devices, err := deck.EnumerateDevices() devices, err := EnumerateDevices()
if err != nil { if err != nil {
slog.Error("discover", "error", err) slog.Error("discover", "error", err)
os.Exit(1) os.Exit(1)
@@ -113,25 +92,3 @@ func discover() {
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID) fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
} }
} }
// switchToPage tells the running daemon (at httpAddr) to activate the given page
// by POSTing to its /api/activate-page endpoint.
func switchToPage(httpAddr, page string) error {
host := httpAddr
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
url := "http://" + host + "/api/activate-page"
body, _ := json.Marshal(map[string]string{"page": page})
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("daemon not running? %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("switch failed (HTTP %d)", resp.StatusCode)
}
return nil
}
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"log/slog"
)
func init() {
slog.Info("media module loaded")
}
// mediaAction is handled inline in action.go via playerctl/wpctl
-23
View File
@@ -1,23 +0,0 @@
pkgbase = streamdeck-lets-go-git
pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI
pkgver = 0.1.0
pkgrel = 3
url = https://github.com/totmin/streamdeck-lets-go
arch = x86_64
arch = aarch64
license = MIT
makedepends = go>=1.26
makedepends = git
depends = libusb
optdepends = librsvg: SVG icon rendering
optdepends = fontconfig: system font detection
provides = streamdeck-lets-go
conflicts = streamdeck-lets-go
source = streamdeck-lets-go-git::git+https://github.com/totmin/streamdeck-lets-go.git
source = streamdeck-lets-go.service
source = 90-streamdeck.rules
b2sums = SKIP
b2sums = SKIP
b2sums = SKIP
pkgname = streamdeck-lets-go-git
-7
View File
@@ -1,7 +0,0 @@
# Elgato Stream Deck — allow access for locally logged-in users
# Vendor 0fd9
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess"
-48
View File
@@ -1,48 +0,0 @@
# Maintainer: totmin <totmin@users.noreply.github.com>
pkgname=streamdeck-lets-go-git
_giturl=https://github.com/totmin/streamdeck-lets-go.git
pkgver=0.1.0
pkgrel=3
pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI"
arch=('x86_64' 'aarch64')
url="https://github.com/totmin/streamdeck-lets-go"
license=('MIT')
depends=('libusb')
optdepends=('librsvg: SVG icon rendering'
'fontconfig: system font detection')
makedepends=('go>=1.26' 'git')
provides=('streamdeck-lets-go')
conflicts=('streamdeck-lets-go')
source=("${pkgname}::git+${_giturl}"
"streamdeck-lets-go.service"
"90-streamdeck.rules")
b2sums=('SKIP'
'SKIP'
'SKIP')
pkgver() {
cd "${srcdir}/${pkgname}"
git describe --always --tags 2>/dev/null || echo "r0.$(git rev-parse --short HEAD)"
}
build() {
cd "${srcdir}/${pkgname}"
export CGO_ENABLED=1
go build \
-ldflags "-s -w" \
-o streamdeck-lets-go ./cmd/streamdeck-lets-go
}
package() {
cd "${srcdir}/${pkgname}"
install -Dm755 streamdeck-lets-go "${pkgdir}/usr/bin/streamdeck-lets-go"
install -Dm644 "${srcdir}/streamdeck-lets-go.service" \
"${pkgdir}/usr/lib/systemd/user/streamdeck-lets-go.service"
install -Dm644 "${srcdir}/90-streamdeck.rules" \
"${pkgdir}/usr/lib/udev/rules.d/90-streamdeck.rules"
}
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=Stream Deck Lets Go daemon
Documentation=https://github.com/Wakatron/streamdeck-lets-go
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/streamdeck-lets-go daemon
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+60 -306
View File
@@ -1,7 +1,6 @@
package render package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -29,7 +28,6 @@ import (
"golang.org/x/image/math/fixed" "golang.org/x/image/math/fixed"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
) )
type keyState struct { type keyState struct {
@@ -46,107 +44,34 @@ type PageManager struct {
mu sync.RWMutex mu sync.RWMutex
pages map[string]*config.PageConfig pages map[string]*config.PageConfig
active string active string
deck *deck.Deck deck *Deck
keyStates map[int]*keyState keyStates map[int]*keyState
displayOutputs map[int]*DisplayOutput displayOutputs map[int]*DisplayOutput
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.Deck) *PageManager { func NewPageManager(deck *Deck) *PageManager {
return &PageManager{ return &PageManager{
pages: make(map[string]*config.PageConfig), pages: make(map[string]*config.PageConfig),
deck: deck, deck: deck,
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()
@@ -167,7 +92,7 @@ func (pm *PageManager) ActivatePage(name string) error {
} }
if page.Background != "" { if page.Background != "" {
if img, err := LoadImage(page.Background, 0, 0); err == nil { if img, err := loadImage(page.Background, 0, 0); err == nil {
if err := pm.deck.FillPanel(img); err != nil { if err := pm.deck.FillPanel(img); err != nil {
slog.Warn("fill panel failed", "error", err) slog.Warn("fill panel failed", "error", err)
} }
@@ -192,12 +117,6 @@ 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
} }
@@ -229,18 +148,14 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
return pm.pages[name] return pm.pages[name]
} }
func (pm *PageManager) GetPageNames() []string { func (pm *PageManager) RenderKey(keyIndex int) {
pm.mu.RLock() pm.mu.RLock()
defer pm.mu.RUnlock() defer pm.mu.RUnlock()
names := make([]string, 0, len(pm.pages)) page := pm.pages[pm.active]
for name := range pm.pages { if page == nil {
names = append(names, name) return
} }
return names for _, k := range page.Keys {
}
func (pm *PageManager) RenderKey(keyIndex int) {
for _, k := range pm.ActiveKeys() {
if k.Index == keyIndex { if k.Index == keyIndex {
pm.renderKey(keyIndex, &k) pm.renderKey(keyIndex, &k)
return return
@@ -249,7 +164,13 @@ func (pm *PageManager) RenderKey(keyIndex int) {
} }
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
for _, k := range pm.ActiveKeys() { pm.mu.RLock()
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,7 +181,7 @@ func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) { func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
fontName := k.Font fontName := k.Font
if fontName == "" { if fontName == "" {
fontName = pm.DefaultFont fontName = pm.defaultFont
} }
fontSize := 18.0 fontSize := 18.0
if k.FontSize != nil { if k.FontSize != nil {
@@ -273,7 +194,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
} }
if k.Icon != "" { if k.Icon != "" {
img, err := LoadImage(k.Icon, pm.deck.KeySize(), faScale) img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale)
if err != nil { if err != nil {
slog.Warn("load icon", "path", k.Icon, "error", err) slog.Warn("load icon", "path", k.Icon, "error", err)
pm.deck.FillColor(idx, 64, 64, 64) pm.deck.FillColor(idx, 64, 64, 64)
@@ -297,11 +218,11 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
} }
onImgFontName := fontName onImgFontName := fontName
if k.Font == "" { if k.Font == "" {
onImgFontName = pm.DefaultFont onImgFontName = pm.defaultFont
} }
if k.Label != "" { if k.Label != "" {
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.ShowLabelBackground); err != nil { if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.showLabelBackground); err != nil {
slog.Warn("write text on image", "error", err) slog.Warn("write text on image", "error", err)
pm.deck.FillImage(idx, img) pm.deck.FillImage(idx, img)
} }
@@ -317,7 +238,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
ks := pm.deck.KeySize() ks := pm.deck.KeySize()
img := image.NewRGBA(image.Rect(0, 0, ks, ks)) img := image.NewRGBA(image.Rect(0, 0, ks, ks))
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.ShowLabelBackground); err != nil { if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.showLabelBackground); err != nil {
slog.Warn("write text on image", "error", err) slog.Warn("write text on image", "error", err)
pm.deck.FillImage(idx, img) pm.deck.FillImage(idx, img)
} }
@@ -325,7 +246,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
} }
slog.Warn("invalid background color", "value", k.Background, "error", err) slog.Warn("invalid background color", "value", k.Background, "error", err)
} }
if err := pm.deck.WriteText(idx, k.Label, color.RGBA{0x21, 0x25, 0x2b, 0xff}, fontName, fontSize); err != nil { if err := pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize); err != nil {
slog.Warn("write text", "error", err) slog.Warn("write text", "error", err)
} }
} else if k.Background != "" { } else if k.Background != "" {
@@ -338,9 +259,7 @@ 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 {
@@ -349,7 +268,7 @@ func (pm *PageManager) StopPeriodicKeys() {
pm.keyStates = make(map[int]*keyState) pm.keyStates = make(map[int]*keyState)
} }
func (pm *PageManager) StartPeriodicKeys() { func (pm *PageManager) startPeriodicKeys() {
pm.mu.Lock() pm.mu.Lock()
page := pm.pages[pm.active] page := pm.pages[pm.active]
if page == nil { if page == nil {
@@ -377,136 +296,11 @@ 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) {
@@ -599,8 +393,15 @@ 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 pm.ActiveKeys() { for _, k := range page.Keys {
if k.Index == idx { if k.Index == idx {
kCopy := k kCopy := k
kc = &kCopy kc = &kCopy
@@ -650,7 +451,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
} }
if kc.Icon != "" { if kc.Icon != "" {
img, err := LoadImage(kc.Icon, pm.deck.KeySize(), faScale) img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale)
if err != nil { if err != nil {
slog.Warn("load icon for display", "path", kc.Icon, "error", err) slog.Warn("load icon for display", "path", kc.Icon, "error", err)
var bg color.Color = color.Black var bg color.Color = color.Black
@@ -676,7 +477,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
} }
} }
composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.ShowLabelBackground) composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.showLabelBackground)
if err := pm.deck.FillImage(idx, composite); err != nil { if err := pm.deck.FillImage(idx, composite); err != nil {
slog.Warn("fill image", "error", err) slog.Warn("fill image", "error", err)
} }
@@ -698,7 +499,13 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
} }
func (pm *PageManager) RefreshDisplayKey(idx int) { func (pm *PageManager) RefreshDisplayKey(idx int) {
for _, k := range pm.ActiveKeys() { pm.mu.RLock()
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 != "" {
@@ -868,7 +675,7 @@ func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.
return rgba return rgba
} }
func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, ShowLabelBackground bool) *image.RGBA { func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, showLabelBackground bool) *image.RGBA {
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling)) g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
g.Draw(rgba, baseImg) g.Draw(rgba, baseImg)
@@ -877,7 +684,7 @@ func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64
if keySize < 72 { if keySize < 72 {
barHeight = 16 barHeight = 16
} }
if ShowLabelBackground { if showLabelBackground {
barRect := image.Rect(0, keySize-barHeight, keySize, keySize) barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over) draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
} }
@@ -1015,7 +822,7 @@ func cacheKey(path string, targetSize int, faScale float64) string {
return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale) return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale)
} }
func LoadImage(path string, targetSize int, faScale float64) (image.Image, error) { func loadImage(path string, targetSize int, faScale float64) (image.Image, error) {
key := cacheKey(path, targetSize, faScale) key := cacheKey(path, targetSize, faScale)
if cached, ok := imageCache.Load(key); ok { if cached, ok := imageCache.Load(key); ok {
return cached.(image.Image), nil return cached.(image.Image), nil
@@ -1052,7 +859,7 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
} }
resolved = p resolved = p
} else if !strings.HasPrefix(path, "/") { } else if !strings.HasPrefix(path, "/") {
resolved = filepath.Join(config.ConfigDir(), path) resolved = filepath.Join(configDir(), path)
} }
if strings.HasSuffix(resolved, ".svg") { if strings.HasSuffix(resolved, ".svg") {
@@ -1085,9 +892,9 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
displaySize = 1 displaySize = 1
} }
// Resize to displaySize preserving aspect ratio (crop to fill) // Resize to displaySize
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize { if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor)) g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
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
@@ -1107,70 +914,17 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
return img, nil return img, nil
} }
func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) { var cachedConfigDir string
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd func configDir() string {
if d.Command != "" { if cachedConfigDir != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command) return cachedConfigDir
} else {
cmd = exec.CommandContext(ctx, d.Script)
} }
var stdout, stderr bytes.Buffer home, err := os.UserHomeDir()
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
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(config.ConfigDir(), script)
}
cmd = exec.CommandContext(ctx, script)
}
cmd.Env = append(os.Environ(),
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", config.ConfigDir()),
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if stderr.Len() > 0 {
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
}
if err != nil { if err != nil {
return nil, fmt.Errorf("execute dynamic_keys: %w", err) cachedConfigDir = "."
return cachedConfigDir
} }
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
var keys []config.KeyConfig return cachedConfigDir
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
} }
+3 -3
View File
@@ -1,4 +1,4 @@
package render package main
import ( import (
"image" "image"
@@ -32,7 +32,7 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool
} }
if k.Icon != "" { if k.Icon != "" {
img, err := LoadImage(k.Icon, keySize, faScale) img, err := loadImage(k.Icon, keySize, faScale)
if err != nil { if err != nil {
img = blankImage(keySize, color.RGBA{64, 64, 64, 255}) img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
} }
@@ -123,7 +123,7 @@ func composeImageWithLabel(src image.Image, text string, keySize int, fontSize f
} }
func renderTextImage(text string, keySize int, fontSize float64) image.Image { func renderTextImage(text string, keySize int, fontSize float64) image.Image {
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA) rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
if text != "" { if text != "" {
face, err := parseDisplayFace(fontSize) face, err := parseDisplayFace(fontSize)
@@ -1,19 +1,17 @@
package daemon package main
import ( import (
"log/slog" "log/slog"
"time" "time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
) )
type Screensaver struct { type Screensaver struct {
enabled bool enabled bool
idleAfter time.Duration idleAfter time.Duration
lastInput time.Time lastInput time.Time
active bool active bool
savedBrightness int savedBrightness int
} }
@@ -52,7 +50,7 @@ func (ss *Screensaver) Check() bool {
return false return false
} }
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) { func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) {
ss.savedBrightness = deck.Brightness() ss.savedBrightness = deck.Brightness()
brightness := cfg.Brightness brightness := cfg.Brightness
@@ -64,7 +62,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
} }
if cfg.Image != "" { if cfg.Image != "" {
img, err := render.LoadImage(cfg.Image, 0, 0) img, err := loadImage(cfg.Image, 0, 0)
if err != nil { if err != nil {
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err) slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
return return
@@ -77,7 +75,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
slog.Info("screensaver activated") slog.Info("screensaver activated")
} }
func (ss *Screensaver) Deactivate(deck *deck.Deck) { func (ss *Screensaver) Deactivate(deck *Deck) {
brightness := ss.savedBrightness brightness := ss.savedBrightness
if brightness <= 0 { if brightness <= 0 {
brightness = 75 brightness = 75
+108 -26
View File
@@ -47,12 +47,45 @@ document.addEventListener('alpine:init', () => {
keyboard: 'Keyboard shortcut', keyboard: 'Keyboard shortcut',
}, },
// ── Dropdown option sets (declarative — add a new dropdown = add a list) ──
actionTypeOptions: [
{ value: 'none', label: 'None' },
{ value: 'command', label: 'Command' },
{ value: 'builtin', label: 'Builtin' },
{ value: 'script', label: 'Script' },
{ value: 'page', label: 'Switch page' },
{ value: 'keyboard', label: 'Keyboard shortcut' },
],
builtinOptions: [
{ value: '', label: '-- select --' },
{ value: 'volume_up', label: 'Volume Up' },
{ value: 'volume_down', label: 'Volume Down' },
{ value: 'volume_mute', label: 'Volume Mute' },
{ value: 'brightness_up', label: 'Brightness Up' },
{ value: 'brightness_down', label: 'Brightness Down' },
{ value: 'media_play_pause', label: 'Play/Pause' },
{ value: 'media_next', label: 'Next Track' },
{ value: 'media_prev', label: 'Previous Track' },
{ value: 'media_stop', label: 'Stop' },
],
fontOptions: [
{ value: 'medium', label: 'Medium' },
{ value: 'regular', label: 'Regular' },
],
// Pages are dynamic — exposed as a getter so dropdowns stay reactive.
get pageOptions() {
return [
{ value: '', label: '-- select --' },
...this.pages.map(p => ({ value: p.name, label: p.name })),
]
},
// ── Init ── // ── Init ──
async init() { async 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()
}, },
@@ -102,7 +135,6 @@ 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)) {
@@ -117,19 +149,6 @@ 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,
@@ -166,13 +185,10 @@ 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 = sourceKeys.find(k => k.index === i) const kc = page.keys.find(k => k.index === i)
const dout = this.displayOutputs[i] const dout = this.displayOutputs[i]
keys.push({ keys.push({
index: i, index: i,
@@ -181,10 +197,9 @@ document.addEventListener('alpine:init', () => {
hasDisplay: !!(kc?.display), hasDisplay: !!(kc?.display),
hasAction: !!(kc?.actions?.length), hasAction: !!(kc?.actions?.length),
actionTypes: this.getActionTypes(kc?.actions || []), actionTypes: this.getActionTypes(kc?.actions || []),
displayBg: (kc?.display && dout?.background) || '', displayBg: dout?.background || '',
displayText: (kc?.display && 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
@@ -204,7 +219,7 @@ document.addEventListener('alpine:init', () => {
const k = kc || {} const k = kc || {}
let url = '/api/render?key_size=72' let url = '/api/render?key_size=72'
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}` if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
const label = (k.display && (dout?.text || this.displayOutputs[k.index]?.text)) || k.label const label = dout?.text || this.displayOutputs[k.index]?.text || k.label
if (label) url += `&label=${encodeURIComponent(label)}` if (label) url += `&label=${encodeURIComponent(label)}`
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}` if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
if (k.font_size != null) url += `&font_size=${k.font_size}` if (k.font_size != null) url += `&font_size=${k.font_size}`
@@ -325,9 +340,6 @@ 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
@@ -742,4 +754,74 @@ document.addEventListener('alpine:init', () => {
return m[1].replace('T', ' ') return m[1].replace('T', ' ')
}, },
})) }))
// ── Reusable dropdown component ──
// Usage:
// x-data="dropdown({ get: () => model, set: v => model = v, options: [...] })"
// `options` may be an array or a function returning one (for reactive lists).
Alpine.data('dropdown', (config = {}) => ({
open: false,
activeIndex: -1,
_get: config.get || (() => ''),
_set: config.set || (() => {}),
_options: config.options || [],
placeholder: config.placeholder || '-- select --',
get optionList() {
return typeof this._options === 'function' ? this._options() : this._options
},
get selectedValue() {
return this._get()
},
get selectedLabel() {
const opt = this.optionList.find(o => o.value === this.selectedValue)
return opt ? opt.label : this.placeholder
},
get isPlaceholder() {
const v = this.selectedValue
const opt = this.optionList.find(o => o.value === v)
return v === '' || v == null || !opt
},
isSelected(value) {
return value === this.selectedValue
},
toggle() {
this.open = !this.open
if (this.open) {
this.activeIndex = this.optionList.findIndex(o => o.value === this.selectedValue)
}
},
close() {
this.open = false
this.activeIndex = -1
},
select(value) {
this._set(value)
this.close()
},
onTriggerKey(e) {
if (!this.open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
this.toggle()
}
return
}
const list = this.optionList
if (e.key === 'ArrowDown') {
e.preventDefault()
this.activeIndex = (this.activeIndex + 1) % list.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
this.activeIndex = (this.activeIndex - 1 + list.length) % list.length
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (this.activeIndex >= 0) this.select(list[this.activeIndex].value)
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation() // keep Escape from also closing the parent modal
this.close()
}
},
}))
}) })
@@ -94,11 +94,8 @@
: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.effective_keys?.length || p.keys.length) + ' keys'"></span> x-text="p.keys.length + ' keys'"></span>
</button> </button>
</template> </template>
</div> </div>
@@ -120,10 +117,20 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Default Font</label> <label>Default Font</label>
<select x-model="settingsForm.font"> <div class="dropdown" x-data="dropdown({ get: () => settingsForm.font, set: v => settingsForm.font = v, options: fontOptions })" @click.outside="close()">
<option value="medium">Medium</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<option value="regular">Regular</option> <span class="dropdown-text" x-text="selectedLabel"></span>
</select> <i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<div class="checkbox-row"> <div class="checkbox-row">
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background"> <input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
@@ -188,12 +195,20 @@
<div class="form-row"> <div class="form-row">
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
<label>Page</label> <label>Page</label>
<select x-model="rule.page"> <div class="dropdown" x-data="dropdown({ get: () => rule.page, set: v => rule.page = v, options: () => pageOptions })" @click.outside="close()">
<option value="">--</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<template x-for="p in pages" :key="p.name"> <span class="dropdown-text" x-text="selectedLabel"></span>
<option :value="p.name" x-text="p.name"></option> <i class="fas fa-chevron-down dropdown-arrow"></i>
</template> </button>
</select> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;"> <div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
<input type="checkbox" x-model="rule.stay"> <input type="checkbox" x-model="rule.stay">
@@ -304,14 +319,20 @@
<template x-for="t in ['tap','long_press','double_tap']" :key="t"> <template x-for="t in ['tap','long_press','double_tap']" :key="t">
<div x-show="editForm.activeTrigger === t"> <div x-show="editForm.activeTrigger === t">
<div class="form-group"> <div class="form-group">
<select x-model="editForm.actions[t].type"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].type, set: v => editForm.actions[t].type = v, options: actionTypeOptions })" @click.outside="close()">
<option value="none">None</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<option value="command">Command</option> <span class="dropdown-text" x-text="selectedLabel"></span>
<option value="builtin">Builtin</option> <i class="fas fa-chevron-down dropdown-arrow"></i>
<option value="script">Script</option> </button>
<option value="page">Switch page</option> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<option value="keyboard">Keyboard shortcut</option> <template x-for="(opt, oi) in optionList" :key="opt.value">
</select> <button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<template x-if="editForm.actions[t].type === 'command'"> <template x-if="editForm.actions[t].type === 'command'">
@@ -328,18 +349,20 @@
<template x-if="editForm.actions[t].type === 'builtin'"> <template x-if="editForm.actions[t].type === 'builtin'">
<div class="form-group"> <div class="form-group">
<select x-model="editForm.actions[t].builtin"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].builtin, set: v => editForm.actions[t].builtin = v, options: builtinOptions })" @click.outside="close()">
<option value="">-- select --</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<option value="volume_up">Volume Up</option> <span class="dropdown-text" x-text="selectedLabel"></span>
<option value="volume_down">Volume Down</option> <i class="fas fa-chevron-down dropdown-arrow"></i>
<option value="volume_mute">Volume Mute</option> </button>
<option value="brightness_up">Brightness Up</option> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<option value="brightness_down">Brightness Down</option> <template x-for="(opt, oi) in optionList" :key="opt.value">
<option value="media_play_pause">Play/Pause</option> <button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<option value="media_next">Next Track</option> <span x-text="opt.label"></span>
<option value="media_prev">Previous Track</option> <i class="fas fa-check dropdown-check"></i>
<option value="media_stop">Stop</option> </button>
</select> </template>
</div>
</div>
</div> </div>
</template> </template>
@@ -350,12 +373,20 @@
</template> </template>
<div class="form-group" x-show="editForm.actions[t].type === 'page'"> <div class="form-group" x-show="editForm.actions[t].type === 'page'">
<select x-model="editForm.actions[t].page"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].page, set: v => editForm.actions[t].page = v, options: () => pageOptions })" @click.outside="close()">
<option value="">-- select --</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<template x-for="p in pages" :key="p.name"> <span class="dropdown-text" x-text="selectedLabel"></span>
<option :value="p.name" x-text="p.name"></option> <i class="fas fa-chevron-down dropdown-arrow"></i>
</template> </button>
</select> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<template x-if="editForm.actions[t].type === 'keyboard'"> <template x-if="editForm.actions[t].type === 'keyboard'">
@@ -401,14 +432,20 @@
<template x-for="phase in ['start','end']" :key="phase"> <template x-for="phase in ['start','end']" :key="phase">
<div x-show="editForm.holdPhase === phase"> <div x-show="editForm.holdPhase === phase">
<div class="form-group"> <div class="form-group">
<select x-model="editForm.actions.hold[phase].type"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].type, set: v => editForm.actions.hold[phase].type = v, options: actionTypeOptions })" @click.outside="close()">
<option value="none">None</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<option value="command">Command</option> <span class="dropdown-text" x-text="selectedLabel"></span>
<option value="builtin">Builtin</option> <i class="fas fa-chevron-down dropdown-arrow"></i>
<option value="script">Script</option> </button>
<option value="page">Switch page</option> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<option value="keyboard">Keyboard shortcut</option> <template x-for="(opt, oi) in optionList" :key="opt.value">
</select> <button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<template x-if="editForm.actions.hold[phase].type === 'command'"> <template x-if="editForm.actions.hold[phase].type === 'command'">
@@ -425,18 +462,20 @@
<template x-if="editForm.actions.hold[phase].type === 'builtin'"> <template x-if="editForm.actions.hold[phase].type === 'builtin'">
<div class="form-group"> <div class="form-group">
<select x-model="editForm.actions.hold[phase].builtin"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].builtin, set: v => editForm.actions.hold[phase].builtin = v, options: builtinOptions })" @click.outside="close()">
<option value="">-- select --</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<option value="volume_up">Volume Up</option> <span class="dropdown-text" x-text="selectedLabel"></span>
<option value="volume_down">Volume Down</option> <i class="fas fa-chevron-down dropdown-arrow"></i>
<option value="volume_mute">Volume Mute</option> </button>
<option value="brightness_up">Brightness Up</option> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<option value="brightness_down">Brightness Down</option> <template x-for="(opt, oi) in optionList" :key="opt.value">
<option value="media_play_pause">Play/Pause</option> <button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<option value="media_next">Next Track</option> <span x-text="opt.label"></span>
<option value="media_prev">Previous Track</option> <i class="fas fa-check dropdown-check"></i>
<option value="media_stop">Stop</option> </button>
</select> </template>
</div>
</div>
</div> </div>
</template> </template>
@@ -447,12 +486,20 @@
</template> </template>
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'"> <div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
<select x-model="editForm.actions.hold[phase].page"> <div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].page, set: v => editForm.actions.hold[phase].page = v, options: () => pageOptions })" @click.outside="close()">
<option value="">-- select --</option> <button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<template x-for="p in pages" :key="p.name"> <span class="dropdown-text" x-text="selectedLabel"></span>
<option :value="p.name" x-text="p.name"></option> <i class="fas fa-chevron-down dropdown-arrow"></i>
</template> </button>
</select> <div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div> </div>
<template x-if="editForm.actions.hold[phase].type === 'keyboard'"> <template x-if="editForm.actions.hold[phase].type === 'keyboard'">
@@ -1,24 +1,52 @@
:root { :root {
--bg: #0a0a10; /* ── One Dark Pro palette ── */
--surface: #141420; /* Surfaces: layered dark greys built around One Dark's #282c34 base */
--surface-hover: #1a1a2a; --bg: #282c34;
--border: #252540; --surface: #2f343f;
--border-light: #333358; --surface-hover: #3a3f4b;
--text: #e4e4f0; --surface-sunken: #21252b;
--text-muted: #7a7a90; --border: #3e4451;
--accent: #6366f1; --border-light: #4b5263;
--accent-glow: rgba(99, 102, 241, 0.3);
--danger: #ef4444; /* Text */
--success: #22c55e; --text: #abb2bf;
--text-muted: #5c6370;
/* Accent — purple (One Dark Pro signature) */
--accent: #c678dd;
--accent-hover: #d292e8;
--accent-active: #b561d0;
--accent-glow: rgba(198, 120, 221, 0.28);
--accent-soft: rgba(198, 120, 221, 0.12);
--on-accent: #21252b; /* dark text on the bright accent — WCAG AA (5.3:1) */
/* Semantic */
--danger: #e06c75;
--danger-soft: rgba(224, 108, 117, 0.12);
--success: #98c379;
--warning: #e5c07b;
/* Radii */
--radius: 12px; --radius: 12px;
--radius-sm: 8px; --radius-sm: 8px;
--radius-xs: 6px; --radius-xs: 6px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
/* Elevation */
--shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.35);
/* Motion */
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 220ms cubic-bezier(0.16, 1, 0.3, 1);
/* Typography */
--font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
[x-cloak] { display: none !important; }
html, body { html, body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
@@ -112,6 +140,7 @@ main {
background: var(--surface); background: var(--surface);
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--border); border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
width: 100%; width: 100%;
max-width: 525px; max-width: 525px;
contain: layout style; contain: layout style;
@@ -140,7 +169,7 @@ main {
.deck-btn:hover { color: var(--text); border-color: var(--border-light); } .deck-btn:hover { color: var(--text); border-color: var(--border-light); }
.deck-btn.active { .deck-btn.active {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
@@ -148,7 +177,7 @@ main {
aspect-ratio: 1; aspect-ratio: 1;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #0d0d18; background: var(--surface-sunken);
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@@ -164,8 +193,9 @@ main {
.key-btn:hover { .key-btn:hover {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow); box-shadow: 0 0 0 1px var(--accent), 0 4px 16px var(--accent-glow);
transform: scale(1.03); transform: scale(1.04);
z-index: 1;
} }
.key-btn:active { transform: scale(0.97); } .key-btn:active { transform: scale(0.97); }
@@ -210,8 +240,9 @@ main {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 100; z-index: 100;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -257,7 +288,7 @@ main {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
position: relative; position: relative;
background: #0d0d18; background: var(--surface-sunken);
cursor: pointer; cursor: pointer;
} }
@@ -303,38 +334,139 @@ main {
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 8px 10px; padding: 9px 11px;
background: var(--bg); background: var(--surface-sunken);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
color: var(--text); color: var(--text);
font-size: 13px; font-size: 13px;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
transition: border-color var(--transition); transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
.form-group input:hover,
.form-group select:hover {
border-color: var(--border-light);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-soft);
} }
.form-group input::placeholder { .form-group input::placeholder {
color: var(--text-muted); color: var(--text-muted);
opacity: 0.5; opacity: 0.7;
} }
.form-group select { .form-group select {
cursor: pointer; cursor: pointer;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%235c6370' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 10px center; background-position: right 10px center;
padding-right: 28px; padding-right: 28px;
} }
/* Custom dropdown
Inline-expand pattern: the menu lives in normal document flow, so it
can never be clipped by an ancestor with overflow:auto (e.g. the modal).
Reusable across every <select> replacement. */
.dropdown {
position: relative;
width: 100%;
}
.dropdown-trigger {
width: 100%;
padding: 9px 11px;
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: left;
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
.dropdown-trigger:hover { border-color: var(--border-light); }
.dropdown.open > .dropdown-trigger,
.dropdown-trigger:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.dropdown-trigger .dropdown-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-trigger.is-placeholder .dropdown-text { color: var(--text-muted); }
.dropdown-arrow {
flex-shrink: 0;
font-size: 10px;
color: var(--text-muted);
transition: transform var(--transition), color var(--transition);
}
.dropdown.open > .dropdown-trigger .dropdown-arrow {
transform: rotate(180deg);
color: var(--accent);
}
.dropdown-menu {
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-xs);
box-shadow: var(--shadow-sm);
overflow-y: auto;
max-height: 260px;
padding: 4px;
}
.dropdown-option {
width: 100%;
padding: 8px 10px;
background: none;
border: none;
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background var(--transition), color var(--transition);
}
.dropdown-option:hover,
.dropdown-option.active {
background: var(--surface-hover);
color: var(--text);
}
.dropdown-option.selected {
color: var(--accent);
background: var(--accent-soft);
}
.dropdown-option .dropdown-check {
font-size: 10px;
opacity: 0;
flex-shrink: 0;
}
.dropdown-option.selected .dropdown-check { opacity: 1; }
.form-row { .form-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -401,18 +533,23 @@ main {
transition: all var(--transition); transition: all var(--transition);
} }
.btn:active { transform: translateY(1px); }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
flex: 1; flex: 1;
} }
.btn-primary:hover { .btn-primary:hover {
background: #4f46e5; background: var(--accent-hover);
box-shadow: 0 0 16px var(--accent-glow); border-color: var(--accent-hover);
box-shadow: 0 4px 16px var(--accent-glow);
} }
.btn-primary:active { background: var(--accent-active); }
.btn-outline { .btn-outline {
background: transparent; background: transparent;
border-color: var(--border); border-color: var(--border);
@@ -655,7 +792,7 @@ main {
.tab-btn:hover { color: var(--text); } .tab-btn:hover { color: var(--text); }
.tab-btn.active { .tab-btn.active {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
} }
/* ── Modifier buttons ── */ /* ── Modifier buttons ── */
@@ -681,7 +818,7 @@ main {
.mod-btn:hover { color: var(--text); border-color: var(--border-light); } .mod-btn:hover { color: var(--text); border-color: var(--border-light); }
.mod-btn.active { .mod-btn.active {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
@@ -771,7 +908,7 @@ main {
.action-tab:hover { color: var(--text); border-color: var(--border-light); } .action-tab:hover { color: var(--text); border-color: var(--border-light); }
.action-tab.active { .action-tab.active {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
.action-tab.configured { .action-tab.configured {
@@ -780,7 +917,7 @@ main {
} }
.action-tab.active.configured { .action-tab.active.configured {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
.tab-check { font-size: 8px; } .tab-check { font-size: 8px; }
@@ -808,6 +945,6 @@ main {
.hold-stab:hover { color: var(--text); border-color: var(--border-light); } .hold-stab:hover { color: var(--text); border-color: var(--border-light); }
.hold-stab.active { .hold-stab.active {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
+30 -49
View File
@@ -1,4 +1,4 @@
package web package main
import ( import (
"context" "context"
@@ -18,8 +18,6 @@ import (
"time" "time"
"streamdeck-lets-go/internal/config" "streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
) )
//go:embed static/* //go:embed static/*
@@ -38,9 +36,9 @@ func init() {
type WebServer struct { type WebServer struct {
cfg *config.Config cfg *config.Config
configPath string configPath string
pm *render.PageManager pm *PageManager
extraPMs []*render.PageManager extraPMs []*PageManager
decks []*deck.Deck decks []*Deck
mu sync.RWMutex mu sync.RWMutex
sseClients map[chan string]struct{} sseClients map[chan string]struct{}
@@ -61,19 +59,19 @@ func (s *WebServer) UpdateConfig(cfg *config.Config) {
s.cfg = cfg s.cfg = cfg
} }
func (s *WebServer) SetPageManager(pm *render.PageManager) { func (s *WebServer) SetPageManager(pm *PageManager) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.pm = pm s.pm = pm
} }
func (s *WebServer) SetDecks(decks []*deck.Deck) { func (s *WebServer) SetDecks(decks []*Deck) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.decks = decks s.decks = decks
} }
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) { func (s *WebServer) SetExtraPageManagers(pms []*PageManager) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.extraPMs = pms s.extraPMs = pms
@@ -174,14 +172,6 @@ func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
s.sseClients[ch] = struct{}{} s.sseClients[ch] = struct{}{}
s.sseMu.Unlock() s.sseMu.Unlock()
s.mu.RLock()
if s.pm != nil {
if active := s.pm.ActivePageName(); active != "" {
ch <- active
}
}
s.mu.RUnlock()
notify := r.Context().Done() notify := r.Context().Done()
go func() { go func() {
<-notify <-notify
@@ -218,7 +208,7 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
} }
s.mu.RLock() s.mu.RLock()
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...) allPMs := append([]*PageManager{s.pm}, s.extraPMs...)
s.mu.RUnlock() s.mu.RUnlock()
for _, pm := range allPMs { for _, pm := range allPMs {
@@ -228,8 +218,8 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
if err := pm.ActivatePage(req.Page); err != nil { if err := pm.ActivatePage(req.Page); err != nil {
slog.Warn("activate page", "page", req.Page, "error", err) slog.Warn("activate page", "page", req.Page, "error", err)
} else { } else {
pm.StopPeriodicKeys() pm.stopPeriodicKeys()
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
} }
@@ -349,27 +339,18 @@ 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 {
pi := pageInfo{ pages = append(pages, 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")
@@ -423,7 +404,7 @@ func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
kc.Background = bg kc.Background = bg
} }
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground) img := RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
@@ -489,7 +470,7 @@ func collectIcons(cfg *config.Config, used map[string]bool) {
} }
func gcImages(cfg *config.Config) { func gcImages(cfg *config.Config) {
imgDir := filepath.Join(config.ConfigDir(), "images") imgDir := filepath.Join(configDir(), "images")
entries, err := os.ReadDir(imgDir) entries, err := os.ReadDir(imgDir)
if err != nil { if err != nil {
return return
@@ -498,7 +479,7 @@ func gcImages(cfg *config.Config) {
used := make(map[string]bool) used := make(map[string]bool)
collectIcons(cfg, used) collectIcons(cfg, used)
backupDir := filepath.Join(config.ConfigDir(), "backups") backupDir := filepath.Join(configDir(), "backups")
backupEntries, _ := os.ReadDir(backupDir) backupEntries, _ := os.ReadDir(backupDir)
for _, be := range backupEntries { for _, be := range backupEntries {
if be.IsDir() { if be.IsDir() {
@@ -600,15 +581,15 @@ func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) { func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
type modelInfo struct { type modelInfo struct {
PID uint16 `json:"pid"` PID uint16 `json:"pid"`
Name string `json:"name"` Name string `json:"name"`
KeysX int `json:"keys_x"` KeysX int `json:"keys_x"`
KeysY int `json:"keys_y"` KeysY int `json:"keys_y"`
KeySize int `json:"key_size"` KeySize int `json:"key_size"`
} }
models := make([]modelInfo, 0, len(deck.KnownDecks)) models := make([]modelInfo, 0, len(knownDecks))
for _, d := range deck.KnownDecks { for _, d := range knownDecks {
models = append(models, modelInfo{ models = append(models, modelInfo{
PID: d.PID, PID: d.PID,
Name: d.Name, Name: d.Name,
@@ -626,7 +607,7 @@ func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
infos := make([]deck.DeckInfo, 0, len(s.decks)) infos := make([]DeckInfo, 0, len(s.decks))
for _, d := range s.decks { for _, d := range s.decks {
infos = append(infos, d.DeckInfo()) infos = append(infos, d.DeckInfo())
} }
@@ -663,7 +644,7 @@ func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
} }
defer file.Close() defer file.Close()
uploadDir := filepath.Join(config.ConfigDir(), "images") uploadDir := filepath.Join(configDir(), "images")
if err := os.MkdirAll(uploadDir, 0755); err != nil { if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
return return