Compare commits

..
29 Commits
Author SHA1 Message Date
totmin bd8895f9ef fix: correct libusb dependency name in AUR package 2026-08-26 09:33:10 +07:00
totmin 4243ee353b chore: update AUR package URLs to totmin/streamdeck-lets-go 2026-08-26 09:26:07 +07:00
totmin f20a499c0f fix: stale display outputs leaking between pages in web UI
Bug: when the StreamDeck was on a non-main page (e.g. dashboard) and the
web UI was opened in a private browser tab, the frontend defaulted to the
first page in config while display outputs from the other page were still
applied to keys sharing the same index.

Fix 1 (backend): send the current active page via SSE immediately on new
client connection so the frontend knows the correct page from the start.

Fix 2 (frontend): only apply display output text/background to keys that
have a display config, preventing cross-page leakage.
2026-07-12 11:27:10 +07:00
totmin 3709926c4a fix depends: libusb-1.0 -> libusb 2026-07-03 09:30:48 +07:00
totmin 07b475918e release: v0.1.0 2026-07-03 09:29:49 +07:00
totmin 78cb2ebb3a fix AUR maintainer email 2026-07-03 09:22:59 +07:00
totmin 232aea67cf add MIT license, AUR packaging files, and credits 2026-06-23 16:59:15 +07:00
totmin f68f7e372f cli: add 'switch' command to change page on running daemon 2026-06-22 14:59:32 +07:00
totmin 7604d2ec39 cli: remove 'serve', fix --help to show commands 2026-06-22 14:52:16 +07:00
totmin e590c9fbc5 cli: add --page flag to start on a specific page 2026-06-22 14:44:16 +07:00
totmin a9c8a5bb8b reconnect: faster retry (1s) and health check (2s) 2026-06-22 10:28:43 +07:00
totmin 76ea5ae4b8 daemon: pause work on device disconnect to save battery 2026-06-22 10:25:10 +07:00
totmin 1b8fe467b4 text-only keys: transparent bg in web UI, #21252b bg on device 2026-06-22 10:14:06 +07:00
totmin 31f33c303e Dynamic key generators: plugin system for auto-generated pages
Add dynamic_keys support to PageConfig — a contract-based plugin
system where any executable outputs a JSON array of KeyConfig to stdout.
The daemon runs the generator on a configurable interval and renders
the keys dynamically on the Stream Deck.

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

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

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

Example & docs:
- config.example.json: games page with dynamic_keys + back button
- README.md: comprehensive Dynamic Key Generators section (architecture
  diagram, config reference, generator contract, merge behavior,
  env vars, error handling, Python + Shell examples, tips)
2026-06-21 21:05:32 +07:00
totmin aa3ba02504 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
totmin ae1d860f67 Fix rendering of keys with background only (no icon or label) 2026-06-17 20:28:25 +07:00
totmin 1312e94c3f Improve icon rendering: fix scaling and centering for emoji, FontAwesome, and SVG icons 2026-06-17 20:19:56 +07:00
totmin c858c824bc Apply show_label_background to device rendering and display keys 2026-06-17 20:19:06 +07:00
totmin f7d3b22e0b Add global show_label_background setting with UI control 2026-06-17 20:19:02 +07:00
totmin 72a17dd7aa fix: svg icons now respect icon_scale parameter 2026-06-17 15:12:17 +07:00
totmin 166c4b8719 fix: x-if->x-show for page select to fix x-model+x-for race 2026-06-17 14:00:35 +07:00
totmin 1415c8b644 fix: load FA locally instead of CDN; Alpine color input error via x-init+ 2026-06-17 13:53:48 +07:00
totmin 164660bed8 fix: imageCache key includes targetSize/faScale; Alpine color input error breaks x-if rendering 2026-06-17 13:22:05 +07:00
totmin 3d0ed33c21 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
totmin 9b55c6618c 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
totmin 5bf8f5d3b0 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
totmin f6f3950d28 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
totmin 35eb27738d 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
totmin 10250b776d 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
46 changed files with 2464 additions and 2509 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
/streamdeck-lets-go streamdeck-lets-go
*.test *.test
*.out *.out
*.exe *.exe
-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 -18
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.
@@ -385,9 +371,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:
+93 -9
View File
@@ -1,18 +1,21 @@
package daemon package main
import ( import (
"bytes"
"context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"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 +76,78 @@ 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 execDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, keyCount int) ([]config.KeyConfig, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
if dg.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", dg.Command)
} else {
script := dg.Script
if !filepath.IsAbs(script) {
script = filepath.Join(configDir(), script)
}
cmd = exec.CommandContext(ctx, script)
}
// Set environment variables for the script
cmd.Env = append(os.Environ(),
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", configDir()),
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Log stderr separately (don't merge into stdout for JSON parsing)
if stderr.Len() > 0 {
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
}
if err != nil {
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
}
// Parse JSON from stdout
var keys []config.KeyConfig
output := stdout.String()
if err := json.Unmarshal([]byte(output), &keys); err != nil {
return nil, fmt.Errorf("parse dynamic_keys JSON: %w", err)
}
return keys, nil
}
func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error {
parts := strings.SplitN(a.Builtin, ":", 2) 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 +225,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 +240,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 +257,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 +360,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"
@@ -265,7 +265,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 +307,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 +323,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 +339,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)
@@ -1,4 +1,4 @@
package daemon package main
import ( import (
"encoding/json" "encoding/json"
+1 -1
View File
@@ -1,4 +1,4 @@
package render package main
import ( import (
"bytes" "bytes"
+27 -30
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 {
@@ -22,7 +19,7 @@ type RunOptions struct {
} }
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 +38,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,11 +54,11 @@ 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]
@@ -95,7 +92,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if err := pm.ActivatePage(startPage); err != nil { if err := pm.ActivatePage(startPage); err != nil {
slog.Warn("activate start page", "error", err) slog.Warn("activate start page", "error", err)
} }
pm.StartPeriodicKeys() pm.startPeriodicKeys()
} }
var windowCh <-chan Window var windowCh <-chan Window
@@ -130,8 +127,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 {
@@ -159,7 +156,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
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. // Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys() primaryPM.stopPeriodicKeys()
asm.Pause() asm.Pause()
primaryDeck.Close() primaryDeck.Close()
@@ -184,7 +181,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
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 +207,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 primaryPM.activeKeys() {
if k.Index == evt.Index { if k.Index == evt.Index {
if len(k.Actions) > 0 { if len(k.Actions) > 0 {
ge.HandleEvent(evt, k.Actions) ge.HandleEvent(evt, k.Actions)
@@ -232,8 +229,8 @@ 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("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)
} }
@@ -262,9 +259,9 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
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)
@@ -286,7 +283,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() {
@@ -298,7 +295,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
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. // Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys() primaryPM.stopPeriodicKeys()
asm.Pause() asm.Pause()
primaryDeck.Close() primaryDeck.Close()
@@ -367,16 +364,16 @@ 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, startPage string) *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(startPage)
(*pm).StartPeriodicKeys() (*pm).startPeriodicKeys()
return newDeck return newDeck
} }
select { select {
+3 -3
View File
@@ -1,4 +1,4 @@
package deck package main
import ( import (
"fmt" "fmt"
@@ -34,7 +34,7 @@ type DeckConfig struct {
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"},
@@ -43,7 +43,7 @@ var KnownDecks = []DeckConfig{
} }
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
} }
+2 -2
View File
@@ -1,7 +1,7 @@
pkgbase = streamdeck-lets-go-git pkgbase = streamdeck-lets-go-git
pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI
pkgver = 0.1.0 pkgver = r0.unknown
pkgrel = 3 pkgrel = 1
url = https://github.com/totmin/streamdeck-lets-go url = https://github.com/totmin/streamdeck-lets-go
arch = x86_64 arch = x86_64
arch = aarch64 arch = aarch64
+2 -2
View File
@@ -3,7 +3,7 @@
pkgname=streamdeck-lets-go-git pkgname=streamdeck-lets-go-git
_giturl=https://github.com/totmin/streamdeck-lets-go.git _giturl=https://github.com/totmin/streamdeck-lets-go.git
pkgver=0.1.0 pkgver=0.1.0
pkgrel=3 pkgrel=1
pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI" pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
url="https://github.com/totmin/streamdeck-lets-go" url="https://github.com/totmin/streamdeck-lets-go"
@@ -32,7 +32,7 @@ build() {
export CGO_ENABLED=1 export CGO_ENABLED=1
go build \ go build \
-ldflags "-s -w" \ -ldflags "-s -w" \
-o streamdeck-lets-go ./cmd/streamdeck-lets-go -o streamdeck-lets-go .
} }
package() { package() {
+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"
+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"
-15
View File
@@ -151,21 +151,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)
-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
+2 -4
View File
@@ -18,8 +18,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() {
@@ -86,7 +84,7 @@ 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,
@@ -99,7 +97,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)
+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
+36 -101
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,30 +44,30 @@ 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 dynamicCancel context.CancelFunc
dynamicKeys []config.KeyConfig dynamicKeys []config.KeyConfig
dynamicKeysMu sync.RWMutex dynamicKeysMu sync.RWMutex
dynamicResults map[string][]config.KeyConfig 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), dynamicResults: make(map[string][]config.KeyConfig),
} }
} }
func (pm *PageManager) ActiveKeys() []config.KeyConfig { func (pm *PageManager) activeKeys() []config.KeyConfig {
pm.mu.RLock() pm.mu.RLock()
page := pm.pages[pm.active] page := pm.pages[pm.active]
pm.mu.RUnlock() pm.mu.RUnlock()
@@ -167,7 +165,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)
} }
@@ -229,18 +227,8 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
return pm.pages[name] return pm.pages[name]
} }
func (pm *PageManager) GetPageNames() []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
}
func (pm *PageManager) RenderKey(keyIndex int) { func (pm *PageManager) RenderKey(keyIndex int) {
for _, k := range pm.ActiveKeys() { 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 +237,7 @@ 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() { for _, k := range pm.activeKeys() {
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 +248,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 +261,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 +285,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 +305,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)
} }
@@ -338,7 +326,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
} }
} }
func (pm *PageManager) StopPeriodicKeys() { func (pm *PageManager) stopPeriodicKeys() {
pm.stopDynamicKeys() pm.stopDynamicKeys()
pm.mu.Lock() pm.mu.Lock()
@@ -349,7 +337,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 {
@@ -486,12 +474,12 @@ func (pm *PageManager) rerenderActivePage() {
pm.mu.RUnlock() pm.mu.RUnlock()
if bg != "" { if bg != "" {
if img, err := LoadImage(bg, 0, 0); err == nil { if img, err := loadImage(bg, 0, 0); err == nil {
pm.deck.FillPanel(img) pm.deck.FillPanel(img)
} }
} }
keys := pm.ActiveKeys() keys := pm.activeKeys()
keyByIndex := make(map[int]config.KeyConfig, len(keys)) keyByIndex := make(map[int]config.KeyConfig, len(keys))
for _, k := range keys { for _, k := range keys {
keyByIndex[k.Index] = k keyByIndex[k.Index] = k
@@ -600,7 +588,7 @@ 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) {
var kc *config.KeyConfig var kc *config.KeyConfig
for _, k := range pm.ActiveKeys() { for _, k := range pm.activeKeys() {
if k.Index == idx { if k.Index == idx {
kCopy := k kCopy := k
kc = &kCopy kc = &kCopy
@@ -650,7 +638,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 +664,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 +686,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
} }
func (pm *PageManager) RefreshDisplayKey(idx int) { func (pm *PageManager) RefreshDisplayKey(idx int) {
for _, k := range pm.ActiveKeys() { for _, k := range pm.activeKeys() {
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 +856,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 +865,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 +1003,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 +1040,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") {
@@ -1107,70 +1095,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
} }
+2 -2
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})
} }
@@ -1,12 +1,10 @@
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 {
@@ -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
+17 -19
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
@@ -218,7 +216,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 +226,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()
} }
} }
@@ -423,7 +421,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 +487,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 +496,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() {
@@ -607,8 +605,8 @@ func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
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 +624,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 +661,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