Compare commits

...
2 Commits
Author SHA1 Message Date
totmin 92f64dbd9b docs: use HTTPS clone URL in README 2026-08-26 10:36:13 +07:00
totmin 8a0c167924 refactor: restructure to standard Go project layout (cmd/ + internal/)
- Move entry point to cmd/streamdeck-lets-go/
- Split package main into internal packages:
  - internal/deck: hardware control (Deck, events, brightness)
  - internal/render: key rendering, icons, fonts, PageManager
  - internal/web: HTTP API + embedded SPA
  - internal/daemon: event loop, autoswitch, screensaver, actions
- Add ConfigDir() to internal/config
- Export cross-package API (Deck, PageManager, WebServer, etc.)
- Move dist/arch/ → packaging/ with updated PKGBUILD/.SRCINFO
- Add Makefile (build, test, vet, fmt, install)
- Update README with new build commands and project structure
- Delete unused media.go stub
2026-08-26 10:35:20 +07:00
45 changed files with 2506 additions and 2598 deletions
+33
View File
@@ -0,0 +1,33 @@
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
+18 -4
View File
@@ -27,7 +27,9 @@
### Install from source
```bash
go build -o streamdeck-lets-go .
make build
# or
go build -o streamdeck-lets-go ./cmd/streamdeck-lets-go
```
### Run
@@ -47,7 +49,19 @@ Open `http://localhost:9090` in your browser.
---
## Configuration
## 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
```
The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI.
@@ -371,9 +385,9 @@ After a configurable idle period the deck dims or shows a custom image.
## Building from source
```bash
git clone git@git.totmin.ru:en2zmax/streamdeck-lets-go.git
git clone https://github.com/totmin/streamdeck-lets-go.git
cd streamdeck-lets-go
go build -o streamdeck-lets-go .
make build
```
Dependencies:
-209
View File
@@ -1,209 +0,0 @@
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
}
-1979
View File
File diff suppressed because it is too large Load Diff
+34 -19
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,6 +151,21 @@ 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)
+9 -93
View File
@@ -1,21 +1,18 @@
package main
package daemon
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, pm *PageManager) error {
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
if a == nil {
return nil
}
@@ -76,78 +73,7 @@ func execScript(a *config.Action) error {
return nil
}
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 {
func execBuiltin(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
parts := strings.SplitN(a.Builtin, ":", 2)
if len(parts) < 1 {
return fmt.Errorf("invalid builtin: %s", a.Builtin)
@@ -225,10 +151,10 @@ func brightnessAction(action string) error {
return exec.Command("sh", "-c", cmd).Run()
}
func pageBuiltinAction(action string, pm *PageManager) error {
func pageBuiltinAction(action string, pm *render.PageManager) error {
switch action {
case "next":
pageNames := pm.PageNames()
pageNames := pm.GetPageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
@@ -240,7 +166,7 @@ func pageBuiltinAction(action string, pm *PageManager) error {
return pm.ActivatePage(pageNames[0])
}
case "prev":
pageNames := pm.PageNames()
pageNames := pm.GetPageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
@@ -257,7 +183,7 @@ func pageBuiltinAction(action string, pm *PageManager) error {
return nil
}
func deckBuiltinAction(action string, deck *Deck) error {
func deckBuiltinAction(action string, deck *deck.Deck) error {
switch action {
case "brightness-up":
current := deck.Brightness()
@@ -360,13 +286,3 @@ 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 main
package daemon
import (
"context"
@@ -265,6 +265,7 @@ 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)
@@ -307,6 +308,7 @@ 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)
@@ -323,6 +325,7 @@ 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)
@@ -339,6 +342,7 @@ 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 main
package daemon
import (
"encoding/json"
+35 -32
View File
@@ -1,4 +1,4 @@
package main
package daemon
import (
"context"
@@ -8,6 +8,9 @@ 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 {
@@ -19,7 +22,7 @@ type RunOptions struct {
}
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web := NewWebServer(cfg, opts.ConfigPath)
web := web.NewWebServer(cfg, opts.ConfigPath)
if !opts.NoDeck || opts.HTTPEnabled {
checkKeyboardTool()
@@ -38,11 +41,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
return nil
}
var decks []*Deck
var decks []*deck.Deck
var err error
for {
decks, err = OpenAllDecks()
decks, err = deck.OpenAllDecks()
if err == nil {
break
}
@@ -54,11 +57,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
}
}
pageMgrs := make([]*PageManager, len(decks))
pageMgrs := make([]*render.PageManager, len(decks))
for i, d := range decks {
pageMgrs[i] = NewPageManager(d)
pageMgrs[i].defaultFont = cfg.Font
pageMgrs[i].showLabelBackground = cfg.ShowLabelBackground
pageMgrs[i] = render.NewPageManager(d)
pageMgrs[i].DefaultFont = cfg.Font
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
pageMgrs[i].LoadPages(cfg.Pages)
}
primaryPM := pageMgrs[0]
@@ -92,7 +95,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
@@ -127,8 +130,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 {
@@ -156,7 +159,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()
@@ -181,7 +184,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
wasSsActive := ssCtrl.IsActive()
ssCtrl.NotifyInput()
if evt.Kind == EventKeyPressed {
if evt.Kind == deck.EventKeyPressed {
if wasSsActive {
ssCtrl.Deactivate(primaryDeck)
@@ -207,7 +210,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)
@@ -229,8 +232,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)
}
@@ -256,14 +259,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 {
@@ -283,7 +286,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() {
@@ -295,7 +298,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()
@@ -364,16 +367,16 @@ func deviceBrightness(cfg *config.Config, serial string) int {
return 75
}
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, startPage string) *Deck {
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck {
for {
newDeck, err := OpenDeck("")
newDeck, err := deck.OpenDeck("")
if err == nil {
*pm = NewPageManager(newDeck)
(*pm).defaultFont = cfg.Font
(*pm).showLabelBackground = cfg.ShowLabelBackground
*pm = render.NewPageManager(newDeck)
(*pm).DefaultFont = cfg.Font
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
(*pm).LoadPages(cfg.Pages)
(*pm).ActivatePage(startPage)
(*pm).startPeriodicKeys()
(*pm).StartPeriodicKeys()
return newDeck
}
select {
+7 -6
View File
@@ -1,10 +1,11 @@
package main
package daemon
import (
"sync"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
)
type ActionCallback func(idx int, a *config.Action)
@@ -95,7 +96,7 @@ func findAction(actions []config.KeyAction, trigger string) *config.Action {
return nil
}
func (ge *GestureEngine) HandleEvent(evt Event, actions []config.KeyAction) {
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) {
state := ge.getState(evt.Index)
if len(actions) == 0 {
@@ -103,14 +104,14 @@ func (ge *GestureEngine) HandleEvent(evt Event, actions []config.KeyAction) {
}
switch evt.Kind {
case EventKeyPressed:
case deck.EventKeyPressed:
ge.handleKeyPress(state, evt, actions)
case EventKeyReleased:
case deck.EventKeyReleased:
ge.handleKeyRelease(state, evt, actions)
}
}
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt Event, actions []config.KeyAction) {
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
state.mu.Lock()
state.cancelTimers()
state.holdActive = false
@@ -130,7 +131,7 @@ func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt Event, actio
state.mu.Unlock()
}
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt Event, actions []config.KeyAction) {
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
state.mu.Lock()
state.cancelTimers()
@@ -1,17 +1,19 @@
package main
package daemon
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
}
@@ -50,7 +52,7 @@ func (ss *Screensaver) Check() bool {
return false
}
func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) {
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
ss.savedBrightness = deck.Brightness()
brightness := cfg.Brightness
@@ -62,7 +64,7 @@ func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) {
}
if cfg.Image != "" {
img, err := loadImage(cfg.Image, 0, 0)
img, err := render.LoadImage(cfg.Image, 0, 0)
if err != nil {
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
return
@@ -75,7 +77,7 @@ func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) {
slog.Info("screensaver activated")
}
func (ss *Screensaver) Deactivate(deck *Deck) {
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
brightness := ss.savedBrightness
if brightness <= 0 {
brightness = 75
+23 -23
View File
@@ -1,4 +1,4 @@
package main
package deck
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"`
+4 -4
View File
@@ -1,4 +1,4 @@
package main
package render
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
+209
View File
@@ -0,0 +1,209 @@
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
}
@@ -1,4 +1,4 @@
package main
package render
import (
"fmt"
@@ -138,4 +138,3 @@ 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 main
package render
import (
"embed"
@@ -14,7 +14,7 @@ var faFonts embed.FS
type faStyle int
const (
faSolid faStyle = iota
faSolid faStyle = iota
faRegular
faBrands
)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package render
import (
"bytes"
+102 -37
View File
@@ -1,6 +1,7 @@
package main
package render
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -28,6 +29,7 @@ import (
"golang.org/x/image/math/fixed"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
)
type keyState struct {
@@ -44,30 +46,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) *PageManager {
func NewPageManager(deck *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()
@@ -165,7 +167,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)
}
@@ -227,8 +229,18 @@ 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
@@ -237,7 +249,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
@@ -248,7 +260,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 {
@@ -261,7 +273,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)
@@ -285,11 +297,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)
}
@@ -305,7 +317,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)
}
@@ -326,7 +338,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
}
}
func (pm *PageManager) stopPeriodicKeys() {
func (pm *PageManager) StopPeriodicKeys() {
pm.stopDynamicKeys()
pm.mu.Lock()
@@ -337,7 +349,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 {
@@ -474,12 +486,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
@@ -588,7 +600,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
@@ -638,7 +650,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
@@ -664,7 +676,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)
}
@@ -686,7 +698,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 != "" {
@@ -856,7 +868,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)
@@ -865,7 +877,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)
}
@@ -1003,7 +1015,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
@@ -1040,7 +1052,7 @@ func loadImage(path string, targetSize int, faScale float64) (image.Image, error
}
resolved = p
} else if !strings.HasPrefix(path, "/") {
resolved = filepath.Join(configDir(), path)
resolved = filepath.Join(config.ConfigDir(), path)
}
if strings.HasSuffix(resolved, ".svg") {
@@ -1095,17 +1107,70 @@ func loadImage(path string, targetSize int, faScale float64) (image.Image, error
return img, nil
}
var cachedConfigDir string
func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
func configDir() string {
if cachedConfigDir != "" {
return cachedConfigDir
var cmd *exec.Cmd
if d.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
} else {
cmd = exec.CommandContext(ctx, d.Script)
}
home, err := os.UserHomeDir()
if err != nil {
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()
}
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
return cachedConfigDir
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 {
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
}
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
}
+2 -2
View File
@@ -1,4 +1,4 @@
package main
package render
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})
}
+24 -22
View File
@@ -1,4 +1,4 @@
package main
package web
import (
"context"
@@ -18,6 +18,8 @@ import (
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
//go:embed static/*
@@ -36,9 +38,9 @@ func init() {
type WebServer struct {
cfg *config.Config
configPath string
pm *PageManager
extraPMs []*PageManager
decks []*Deck
pm *render.PageManager
extraPMs []*render.PageManager
decks []*deck.Deck
mu sync.RWMutex
sseClients map[chan string]struct{}
@@ -59,19 +61,19 @@ func (s *WebServer) UpdateConfig(cfg *config.Config) {
s.cfg = cfg
}
func (s *WebServer) SetPageManager(pm *PageManager) {
func (s *WebServer) SetPageManager(pm *render.PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.pm = pm
}
func (s *WebServer) SetDecks(decks []*Deck) {
func (s *WebServer) SetDecks(decks []*deck.Deck) {
s.mu.Lock()
defer s.mu.Unlock()
s.decks = decks
}
func (s *WebServer) SetExtraPageManagers(pms []*PageManager) {
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.extraPMs = pms
@@ -216,7 +218,7 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
}
s.mu.RLock()
allPMs := append([]*PageManager{s.pm}, s.extraPMs...)
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
s.mu.RUnlock()
for _, pm := range allPMs {
@@ -226,8 +228,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()
}
}
@@ -421,7 +423,7 @@ func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
kc.Background = bg
}
img := RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache")
@@ -487,7 +489,7 @@ func collectIcons(cfg *config.Config, used map[string]bool) {
}
func gcImages(cfg *config.Config) {
imgDir := filepath.Join(configDir(), "images")
imgDir := filepath.Join(config.ConfigDir(), "images")
entries, err := os.ReadDir(imgDir)
if err != nil {
return
@@ -496,7 +498,7 @@ func gcImages(cfg *config.Config) {
used := make(map[string]bool)
collectIcons(cfg, used)
backupDir := filepath.Join(configDir(), "backups")
backupDir := filepath.Join(config.ConfigDir(), "backups")
backupEntries, _ := os.ReadDir(backupDir)
for _, be := range backupEntries {
if be.IsDir() {
@@ -598,15 +600,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(knownDecks))
for _, d := range knownDecks {
models := make([]modelInfo, 0, len(deck.KnownDecks))
for _, d := range deck.KnownDecks {
models = append(models, modelInfo{
PID: d.PID,
Name: d.Name,
@@ -624,7 +626,7 @@ func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
infos := make([]DeckInfo, 0, len(s.decks))
infos := make([]deck.DeckInfo, 0, len(s.decks))
for _, d := range s.decks {
infos = append(infos, d.DeckInfo())
}
@@ -661,7 +663,7 @@ func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
uploadDir := filepath.Join(configDir(), "images")
uploadDir := filepath.Join(config.ConfigDir(), "images")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
return
-135
View File
@@ -1,135 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"streamdeck-lets-go/internal/config"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
httpAddr := fs.String("addr", ":9090", "web UI listen address")
startPage := fs.String("page", "", "page to activate on startup (default: config's default_page)")
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
verbose := fs.Bool("v", false, "verbose output")
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-help" || os.Args[1] == "-h" {
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
fmt.Fprintf(os.Stderr, " switch switch running daemon to a page (e.g. 'switch games')\n")
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
fmt.Fprintf(os.Stderr, "\nFlags:\n")
fs.PrintDefaults()
os.Exit(0)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "daemon":
fs.Parse(args)
case "switch":
fs.Parse(args)
if fs.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go switch <page>\n")
os.Exit(1)
}
if err := switchToPage(*httpAddr, fs.Arg(0)); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("switched to page %q\n", fs.Arg(0))
return
case "discover":
discover()
return
default:
if cmd == "serve" {
fmt.Fprintf(os.Stderr, "streamdeck-lets-go: 'serve' removed; use 'daemon --no-deck' instead\n")
os.Exit(1)
}
fs.Parse(os.Args[1:])
}
if *verbose {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))
}
cfg, err := config.LoadConfig(*configPath)
if err != nil {
slog.Error("config", "error", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
httpEnabled := true
if err := Run(ctx, cfg, RunOptions{
ConfigPath: *configPath,
HTTPAddr: *httpAddr,
HTTPEnabled: httpEnabled,
NoDeck: *noDeck,
StartPage: *startPage,
}); err != nil {
slog.Error("run", "error", err)
os.Exit(1)
}
}
func discover() {
devices, err := EnumerateDevices()
if err != nil {
slog.Error("discover", "error", err)
os.Exit(1)
}
if len(devices) == 0 {
fmt.Println("No Stream Deck devices found.")
return
}
fmt.Printf("Found %d Stream Deck(s):\n", len(devices))
for _, d := range devices {
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
}
}
// switchToPage tells the running daemon (at httpAddr) to activate the given page
// by POSTing to its /api/activate-page endpoint.
func switchToPage(httpAddr, page string) error {
host := httpAddr
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
url := "http://" + host + "/api/activate-page"
body, _ := json.Marshal(map[string]string{"page": page})
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("daemon not running? %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("switch failed (HTTP %d)", resp.StatusCode)
}
return nil
}
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"log/slog"
)
func init() {
slog.Info("media module loaded")
}
// mediaAction is handled inline in action.go via playerctl/wpctl
+2 -2
View File
@@ -14,8 +14,8 @@ pkgbase = streamdeck-lets-go-git
provides = streamdeck-lets-go
conflicts = streamdeck-lets-go
source = streamdeck-lets-go-git::git+https://github.com/totmin/streamdeck-lets-go.git
source = streamdeck-lets-go.service
source = 90-streamdeck.rules
source = packaging/streamdeck-lets-go.service
source = packaging/90-streamdeck.rules
b2sums = SKIP
b2sums = SKIP
b2sums = SKIP
+5 -5
View File
@@ -16,8 +16,8 @@ provides=('streamdeck-lets-go')
conflicts=('streamdeck-lets-go')
source=("${pkgname}::git+${_giturl}"
"streamdeck-lets-go.service"
"90-streamdeck.rules")
"packaging/streamdeck-lets-go.service"
"packaging/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 .
-o streamdeck-lets-go ./cmd/streamdeck-lets-go
}
package() {
@@ -40,9 +40,9 @@ package() {
install -Dm755 streamdeck-lets-go "${pkgdir}/usr/bin/streamdeck-lets-go"
install -Dm644 "${srcdir}/streamdeck-lets-go.service" \
install -Dm644 "${srcdir}/packaging/streamdeck-lets-go.service" \
"${pkgdir}/usr/lib/systemd/user/streamdeck-lets-go.service"
install -Dm644 "${srcdir}/90-streamdeck.rules" \
install -Dm644 "${srcdir}/packaging/90-streamdeck.rules" \
"${pkgdir}/usr/lib/udev/rules.d/90-streamdeck.rules"
}