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
*.out
*.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
```bash
make build
# or
go build -o streamdeck-lets-go ./cmd/streamdeck-lets-go
go build -o streamdeck-lets-go .
```
### Run
@@ -49,19 +47,7 @@ Open `http://localhost:9090` in your browser.
---
## Project structure
```
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
```
## Configuration
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
```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
make build
go build -o streamdeck-lets-go .
```
Dependencies:
+93 -9
View File
@@ -1,18 +1,21 @@
package daemon
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"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 {
return nil
}
@@ -73,7 +76,78 @@ func execScript(a *config.Action) error {
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)
if len(parts) < 1 {
return fmt.Errorf("invalid builtin: %s", a.Builtin)
@@ -151,10 +225,10 @@ func brightnessAction(action string) error {
return exec.Command("sh", "-c", cmd).Run()
}
func pageBuiltinAction(action string, pm *render.PageManager) error {
func pageBuiltinAction(action string, pm *PageManager) error {
switch action {
case "next":
pageNames := pm.GetPageNames()
pageNames := pm.PageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
@@ -166,7 +240,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
return pm.ActivatePage(pageNames[0])
}
case "prev":
pageNames := pm.GetPageNames()
pageNames := pm.PageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
@@ -183,7 +257,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
return nil
}
func deckBuiltinAction(action string, deck *deck.Deck) error {
func deckBuiltinAction(action string, deck *Deck) error {
switch action {
case "brightness-up":
current := deck.Brightness()
@@ -286,3 +360,13 @@ func execYDOTool(keys string) error {
cmd.Stderr = os.Stderr
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 (
"context"
@@ -265,7 +265,6 @@ func (d *niriDetector) getWindow(ctx context.Context) Window {
}
type gnomeDetector struct{ basePoll }
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
@@ -308,7 +307,6 @@ func parseGnomeEval(out string) string {
}
type kdeDetector struct{ basePoll }
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
@@ -325,7 +323,6 @@ func (d *kdeDetector) getWindow(ctx context.Context) Window {
}
type x11Detector struct{ basePoll }
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
@@ -342,7 +339,6 @@ func (d *x11Detector) getWindow(ctx context.Context) Window {
}
type portalDetector struct{ basePoll }
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
@@ -1,4 +1,4 @@
package daemon
package main
import (
"encoding/json"
+4 -4
View File
@@ -1,4 +1,4 @@
package render
package main
import (
"bytes"
@@ -16,9 +16,9 @@ import (
)
type cbdtIndex struct {
once sync.Once
imgs []image.Image
scale int
once sync.Once
imgs []image.Image
scale int
}
var emojiCBDT cbdtIndex
+32 -35
View File
@@ -1,4 +1,4 @@
package daemon
package main
import (
"context"
@@ -8,9 +8,6 @@ import (
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
"streamdeck-lets-go/internal/web"
)
type RunOptions struct {
@@ -22,7 +19,7 @@ type RunOptions struct {
}
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 {
checkKeyboardTool()
@@ -41,11 +38,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
return nil
}
var decks []*deck.Deck
var decks []*Deck
var err error
for {
decks, err = deck.OpenAllDecks()
decks, err = OpenAllDecks()
if err == nil {
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 {
pageMgrs[i] = render.NewPageManager(d)
pageMgrs[i].DefaultFont = cfg.Font
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
pageMgrs[i] = NewPageManager(d)
pageMgrs[i].defaultFont = cfg.Font
pageMgrs[i].showLabelBackground = cfg.ShowLabelBackground
pageMgrs[i].LoadPages(cfg.Pages)
}
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 {
slog.Warn("activate start page", "error", err)
}
pm.StartPeriodicKeys()
pm.startPeriodicKeys()
}
var windowCh <-chan Window
@@ -130,8 +127,8 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
asm.NotifyManualPage(a.Page)
}
for _, pm := range pageMgrs {
pm.StopPeriodicKeys()
pm.StartPeriodicKeys()
pm.stopPeriodicKeys()
pm.startPeriodicKeys()
}
web.BroadcastPageChange(newPage)
} else {
@@ -159,7 +156,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
slog.Warn("deck event channel closed, attempting reconnect...")
// Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys()
primaryPM.stopPeriodicKeys()
asm.Pause()
primaryDeck.Close()
@@ -184,7 +181,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
wasSsActive := ssCtrl.IsActive()
ssCtrl.NotifyInput()
if evt.Kind == deck.EventKeyPressed {
if evt.Kind == EventKeyPressed {
if wasSsActive {
ssCtrl.Deactivate(primaryDeck)
@@ -210,7 +207,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if page == nil {
continue
}
for _, k := range primaryPM.ActiveKeys() {
for _, k := range primaryPM.activeKeys() {
if k.Index == evt.Index {
if len(k.Actions) > 0 {
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 {
slog.Warn("auto-switch: activate page", "error", err)
}
pm.StopPeriodicKeys()
pm.StartPeriodicKeys()
pm.stopPeriodicKeys()
pm.startPeriodicKeys()
}
web.BroadcastPageChange(page)
}
@@ -259,14 +256,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
for _, d := range decks {
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
}
var activePages []string
for _, pm := range pageMgrs {
activePages = append(activePages, pm.ActivePageName())
pm.StopPeriodicKeys()
pm.DefaultFont = cfg.Font
pm.ShowLabelBackground = cfg.ShowLabelBackground
pm.LoadPages(cfg.Pages)
}
var activePages []string
for _, pm := range pageMgrs {
activePages = append(activePages, pm.ActivePageName())
pm.stopPeriodicKeys()
pm.defaultFont = cfg.Font
pm.showLabelBackground = cfg.ShowLabelBackground
pm.LoadPages(cfg.Pages)
}
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
asm.Reload(cfg.AutoSwitch)
if len(cfg.AutoSwitch) > 0 && detector == nil {
@@ -286,7 +283,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if err := pm.ActivatePage(page); err != nil {
slog.Warn("reload: activate page", "error", err)
}
pm.StartPeriodicKeys()
pm.startPeriodicKeys()
}
}
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)
// Stop all periodic scripts to avoid wasting cycles on a closed device.
primaryPM.StopPeriodicKeys()
primaryPM.stopPeriodicKeys()
asm.Pause()
primaryDeck.Close()
@@ -367,16 +364,16 @@ func deviceBrightness(cfg *config.Config, serial string) int {
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 {
newDeck, err := deck.OpenDeck("")
newDeck, err := OpenDeck("")
if err == nil {
*pm = render.NewPageManager(newDeck)
(*pm).DefaultFont = cfg.Font
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
*pm = NewPageManager(newDeck)
(*pm).defaultFont = cfg.Font
(*pm).showLabelBackground = cfg.ShowLabelBackground
(*pm).LoadPages(cfg.Pages)
(*pm).ActivatePage(startPage)
(*pm).StartPeriodicKeys()
(*pm).startPeriodicKeys()
return newDeck
}
select {
+23 -23
View File
@@ -1,4 +1,4 @@
package deck
package main
import (
"fmt"
@@ -22,28 +22,28 @@ type DeviceInfo struct {
}
type DeckConfig struct {
PID uint16
Name string
KeysX int
KeysY int
KeySize int
ImageFmt string
Rotate bool
Convert bool
HasDials bool
HasTouch bool
PID uint16
Name string
KeysX int
KeysY int
KeySize int
ImageFmt string
Rotate bool
Convert bool
HasDials bool
HasTouch bool
}
var KnownDecks = []DeckConfig{
{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: 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: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
var knownDecks = []DeckConfig{
{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: 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: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
}
func findConfig(pid uint16) (DeckConfig, bool) {
for _, d := range KnownDecks {
for _, d := range knownDecks {
if d.PID == pid {
return d, true
}
@@ -161,12 +161,12 @@ func (d *Deck) 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) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
func (d *Deck) KeySize() int { return d.cfg.KeySize }
func (d *Deck) Config() DeckConfig { return d.cfg }
func (d *Deck) Model() string { return d.cfg.Name }
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
func (d *Deck) KeySize() int { return d.cfg.KeySize }
func (d *Deck) Config() DeckConfig { return d.cfg }
func (d *Deck) Model() string { return d.cfg.Name }
type DeckInfo struct {
Serial string `json:"serial"`
+2 -2
View File
@@ -1,7 +1,7 @@
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
pkgver = r0.unknown
pkgrel = 1
url = https://github.com/totmin/streamdeck-lets-go
arch = x86_64
arch = aarch64
+4 -4
View File
@@ -3,7 +3,7 @@
pkgname=streamdeck-lets-go-git
_giturl=https://github.com/totmin/streamdeck-lets-go.git
pkgver=0.1.0
pkgrel=3
pkgrel=1
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"
@@ -16,8 +16,8 @@ provides=('streamdeck-lets-go')
conflicts=('streamdeck-lets-go')
source=("${pkgname}::git+${_giturl}"
"streamdeck-lets-go.service"
"90-streamdeck.rules")
"streamdeck-lets-go.service"
"90-streamdeck.rules")
b2sums=('SKIP'
'SKIP'
'SKIP')
@@ -32,7 +32,7 @@ build() {
export CGO_ENABLED=1
go build \
-ldflags "-s -w" \
-o streamdeck-lets-go ./cmd/streamdeck-lets-go
-o streamdeck-lets-go .
}
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 (
"fmt"
@@ -138,3 +138,4 @@ func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.I
func isFAIconRef(path string) bool {
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
}
@@ -1,4 +1,4 @@
package render
package main
import (
"embed"
@@ -14,7 +14,7 @@ var faFonts embed.FS
type faStyle int
const (
faSolid faStyle = iota
faSolid faStyle = iota
faRegular
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 (
"sync"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
)
type ActionCallback func(idx int, a *config.Action)
@@ -96,7 +95,7 @@ func findAction(actions []config.KeyAction, trigger string) *config.Action {
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)
if len(actions) == 0 {
@@ -104,14 +103,14 @@ func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction)
}
switch evt.Kind {
case deck.EventKeyPressed:
case EventKeyPressed:
ge.handleKeyPress(state, evt, actions)
case deck.EventKeyReleased:
case EventKeyReleased:
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.cancelTimers()
state.holdActive = false
@@ -131,7 +130,7 @@ func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event,
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.cancelTimers()
+1 -1
View File
@@ -1,4 +1,4 @@
package render
package main
import (
"bytes"
+19 -34
View File
@@ -11,16 +11,16 @@ import (
)
type Config struct {
Version int `json:"version"`
LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"`
ShowLabelBackground bool `json:"show_label_background"`
Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"`
Version int `json:"version"`
LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"`
ShowLabelBackground bool `json:"show_label_background"`
Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"`
}
type TimingConfig struct {
@@ -57,15 +57,15 @@ type DynamicKeyGen struct {
}
type KeyConfig struct {
Index int `json:"index"`
Icon string `json:"icon,omitempty"`
Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"`
Index int `json:"index"`
Icon string `json:"icon,omitempty"`
Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"`
}
type KeyAction struct {
@@ -151,21 +151,6 @@ func ConfigPath(path string) string {
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) {
path = ConfigPath(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"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/daemon"
"streamdeck-lets-go/internal/deck"
)
func main() {
@@ -86,7 +84,7 @@ func main() {
defer cancel()
httpEnabled := true
if err := daemon.Run(ctx, cfg, daemon.RunOptions{
if err := Run(ctx, cfg, RunOptions{
ConfigPath: *configPath,
HTTPAddr: *httpAddr,
HTTPEnabled: httpEnabled,
@@ -99,7 +97,7 @@ func main() {
}
func discover() {
devices, err := deck.EnumerateDevices()
devices, err := EnumerateDevices()
if err != nil {
slog.Error("discover", "error", err)
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 (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -29,7 +28,6 @@ import (
"golang.org/x/image/math/fixed"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
)
type keyState struct {
@@ -46,30 +44,30 @@ type PageManager struct {
mu sync.RWMutex
pages map[string]*config.PageConfig
active string
deck *deck.Deck
deck *Deck
keyStates map[int]*keyState
displayOutputs map[int]*DisplayOutput
displayMu sync.RWMutex
DefaultFont string
ShowLabelBackground bool
defaultFont string
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{
pages: make(map[string]*config.PageConfig),
deck: deck,
keyStates: make(map[int]*keyState),
displayOutputs: make(map[int]*DisplayOutput),
DefaultFont: "medium",
defaultFont: "medium",
dynamicResults: make(map[string][]config.KeyConfig),
}
}
func (pm *PageManager) ActiveKeys() []config.KeyConfig {
func (pm *PageManager) activeKeys() []config.KeyConfig {
pm.mu.RLock()
page := pm.pages[pm.active]
pm.mu.RUnlock()
@@ -167,7 +165,7 @@ func (pm *PageManager) ActivatePage(name string) error {
}
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 {
slog.Warn("fill panel failed", "error", err)
}
@@ -229,18 +227,8 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
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) {
for _, k := range pm.ActiveKeys() {
for _, k := range pm.activeKeys() {
if k.Index == keyIndex {
pm.renderKey(keyIndex, &k)
return
@@ -249,7 +237,7 @@ func (pm *PageManager) RenderKey(keyIndex int) {
}
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 {
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
return
@@ -260,7 +248,7 @@ func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
fontName := k.Font
if fontName == "" {
fontName = pm.DefaultFont
fontName = pm.defaultFont
}
fontSize := 18.0
if k.FontSize != nil {
@@ -273,7 +261,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
}
if k.Icon != "" {
img, err := LoadImage(k.Icon, pm.deck.KeySize(), faScale)
img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale)
if err != nil {
slog.Warn("load icon", "path", k.Icon, "error", err)
pm.deck.FillColor(idx, 64, 64, 64)
@@ -297,11 +285,11 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
}
onImgFontName := fontName
if k.Font == "" {
onImgFontName = pm.DefaultFont
onImgFontName = pm.defaultFont
}
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)
pm.deck.FillImage(idx, img)
}
@@ -317,7 +305,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
ks := pm.deck.KeySize()
img := image.NewRGBA(image.Rect(0, 0, ks, ks))
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)
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.mu.Lock()
@@ -349,7 +337,7 @@ func (pm *PageManager) StopPeriodicKeys() {
pm.keyStates = make(map[int]*keyState)
}
func (pm *PageManager) StartPeriodicKeys() {
func (pm *PageManager) startPeriodicKeys() {
pm.mu.Lock()
page := pm.pages[pm.active]
if page == nil {
@@ -486,12 +474,12 @@ func (pm *PageManager) rerenderActivePage() {
pm.mu.RUnlock()
if bg != "" {
if img, err := LoadImage(bg, 0, 0); err == nil {
if img, err := loadImage(bg, 0, 0); err == nil {
pm.deck.FillPanel(img)
}
}
keys := pm.ActiveKeys()
keys := pm.activeKeys()
keyByIndex := make(map[int]config.KeyConfig, len(keys))
for _, k := range keys {
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) {
var kc *config.KeyConfig
for _, k := range pm.ActiveKeys() {
for _, k := range pm.activeKeys() {
if k.Index == idx {
kCopy := k
kc = &kCopy
@@ -650,7 +638,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
}
if kc.Icon != "" {
img, err := LoadImage(kc.Icon, pm.deck.KeySize(), faScale)
img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale)
if err != nil {
slog.Warn("load icon for display", "path", kc.Icon, "error", err)
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 {
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) {
for _, k := range pm.ActiveKeys() {
for _, k := range pm.activeKeys() {
if k.Index == idx && k.Display != nil {
timeout := 30 * time.Second
if k.Display.Timeout != "" {
@@ -868,7 +856,7 @@ func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.
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))
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
g.Draw(rgba, baseImg)
@@ -877,7 +865,7 @@ func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64
if keySize < 72 {
barHeight = 16
}
if ShowLabelBackground {
if showLabelBackground {
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
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)
}
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)
if cached, ok := imageCache.Load(key); ok {
return cached.(image.Image), nil
@@ -1052,7 +1040,7 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
}
resolved = p
} else if !strings.HasPrefix(path, "/") {
resolved = filepath.Join(config.ConfigDir(), path)
resolved = filepath.Join(configDir(), path)
}
if strings.HasSuffix(resolved, ".svg") {
@@ -1107,70 +1095,17 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
return img, nil
}
func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cachedConfigDir string
var cmd *exec.Cmd
if d.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
} else {
cmd = exec.CommandContext(ctx, d.Script)
func configDir() string {
if cachedConfigDir != "" {
return cachedConfigDir
}
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(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())
}
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
cachedConfigDir = "."
return cachedConfigDir
}
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
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
return cachedConfigDir
}
+2 -2
View File
@@ -1,4 +1,4 @@
package render
package main
import (
"image"
@@ -32,7 +32,7 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool
}
if k.Icon != "" {
img, err := LoadImage(k.Icon, keySize, faScale)
img, err := loadImage(k.Icon, keySize, faScale)
if err != nil {
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
}
@@ -1,19 +1,17 @@
package daemon
package main
import (
"log/slog"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
type Screensaver struct {
enabled bool
idleAfter time.Duration
lastInput time.Time
active bool
enabled bool
idleAfter time.Duration
lastInput time.Time
active bool
savedBrightness int
}
@@ -52,7 +50,7 @@ func (ss *Screensaver) Check() bool {
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()
brightness := cfg.Brightness
@@ -64,7 +62,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
}
if cfg.Image != "" {
img, err := render.LoadImage(cfg.Image, 0, 0)
img, err := loadImage(cfg.Image, 0, 0)
if err != nil {
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
return
@@ -77,7 +75,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
slog.Info("screensaver activated")
}
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
func (ss *Screensaver) Deactivate(deck *Deck) {
brightness := ss.savedBrightness
if brightness <= 0 {
brightness = 75
+22 -24
View File
@@ -1,4 +1,4 @@
package web
package main
import (
"context"
@@ -18,8 +18,6 @@ import (
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
//go:embed static/*
@@ -38,9 +36,9 @@ func init() {
type WebServer struct {
cfg *config.Config
configPath string
pm *render.PageManager
extraPMs []*render.PageManager
decks []*deck.Deck
pm *PageManager
extraPMs []*PageManager
decks []*Deck
mu sync.RWMutex
sseClients map[chan string]struct{}
@@ -61,19 +59,19 @@ func (s *WebServer) UpdateConfig(cfg *config.Config) {
s.cfg = cfg
}
func (s *WebServer) SetPageManager(pm *render.PageManager) {
func (s *WebServer) SetPageManager(pm *PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.pm = pm
}
func (s *WebServer) SetDecks(decks []*deck.Deck) {
func (s *WebServer) SetDecks(decks []*Deck) {
s.mu.Lock()
defer s.mu.Unlock()
s.decks = decks
}
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
func (s *WebServer) SetExtraPageManagers(pms []*PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.extraPMs = pms
@@ -218,7 +216,7 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
}
s.mu.RLock()
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
allPMs := append([]*PageManager{s.pm}, s.extraPMs...)
s.mu.RUnlock()
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 {
slog.Warn("activate page", "page", req.Page, "error", err)
} else {
pm.StopPeriodicKeys()
pm.StartPeriodicKeys()
pm.stopPeriodicKeys()
pm.startPeriodicKeys()
}
}
@@ -423,7 +421,7 @@ func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
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("Cache-Control", "no-cache")
@@ -489,7 +487,7 @@ func collectIcons(cfg *config.Config, used map[string]bool) {
}
func gcImages(cfg *config.Config) {
imgDir := filepath.Join(config.ConfigDir(), "images")
imgDir := filepath.Join(configDir(), "images")
entries, err := os.ReadDir(imgDir)
if err != nil {
return
@@ -498,7 +496,7 @@ func gcImages(cfg *config.Config) {
used := make(map[string]bool)
collectIcons(cfg, used)
backupDir := filepath.Join(config.ConfigDir(), "backups")
backupDir := filepath.Join(configDir(), "backups")
backupEntries, _ := os.ReadDir(backupDir)
for _, be := range backupEntries {
if be.IsDir() {
@@ -600,15 +598,15 @@ func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
type modelInfo struct {
PID uint16 `json:"pid"`
Name string `json:"name"`
KeysX int `json:"keys_x"`
KeysY int `json:"keys_y"`
KeySize int `json:"key_size"`
PID uint16 `json:"pid"`
Name string `json:"name"`
KeysX int `json:"keys_x"`
KeysY int `json:"keys_y"`
KeySize int `json:"key_size"`
}
models := make([]modelInfo, 0, len(deck.KnownDecks))
for _, d := range deck.KnownDecks {
models := make([]modelInfo, 0, len(knownDecks))
for _, d := range knownDecks {
models = append(models, modelInfo{
PID: d.PID,
Name: d.Name,
@@ -626,7 +624,7 @@ func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
infos := make([]deck.DeckInfo, 0, len(s.decks))
infos := make([]DeckInfo, 0, len(s.decks))
for _, d := range s.decks {
infos = append(infos, d.DeckInfo())
}
@@ -663,7 +661,7 @@ func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
uploadDir := filepath.Join(config.ConfigDir(), "images")
uploadDir := filepath.Join(configDir(), "images")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
return