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
This commit is contained in:
Maksim Totmin
2026-08-26 10:35:20 +07:00
parent 598bbe9ff1
commit 41856e6aaa
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 git@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
+15
View File
@@ -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"
+30 -27
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)
}
@@ -259,9 +262,9 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
var activePages []string
for _, pm := range pageMgrs {
activePages = append(activePages, pm.ActivePageName())
pm.stopPeriodicKeys()
pm.defaultFont = cfg.Font
pm.showLabelBackground = cfg.ShowLabelBackground
pm.StopPeriodicKeys()
pm.DefaultFont = cfg.Font
pm.ShowLabelBackground = cfg.ShowLabelBackground
pm.LoadPages(cfg.Pages)
}
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
@@ -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,10 +1,12 @@
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 {
@@ -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
+3 -3
View File
@@ -1,4 +1,4 @@
package main
package deck
import (
"fmt"
@@ -34,7 +34,7 @@ type DeckConfig struct {
HasTouch bool
}
var knownDecks = []DeckConfig{
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"},
@@ -43,7 +43,7 @@ var knownDecks = []DeckConfig{
}
func findConfig(pid uint16) (DeckConfig, bool) {
for _, d := range knownDecks {
for _, d := range KnownDecks {
if d.PID == pid {
return d, true
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package render
import (
"bytes"
+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"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package render
import (
"bytes"
+101 -36
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()
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())
}
if err != nil {
cachedConfigDir = "."
return cachedConfigDir
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
}
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
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
}
+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})
}
+19 -17
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() {
@@ -605,8 +607,8 @@ func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
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"
}