Compare commits
33
Commits
e676b74c32
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7c6a23d5 | ||
|
|
db85a371af | ||
|
|
c1bc4ddf15 | ||
|
|
41856e6aaa | ||
|
|
598bbe9ff1 | ||
|
|
f32a64ad10 | ||
|
|
cbe8066606 | ||
|
|
3a16476d58 | ||
|
|
331458c97a | ||
|
|
5f95f4c83f | ||
|
|
4cafbe83ee | ||
|
|
7e36fa7415 | ||
|
|
212768df9a | ||
|
|
3cba4fe436 | ||
|
|
3e7a55550f | ||
|
|
62674eb3f5 | ||
|
|
13bf035d2d | ||
|
|
22caab4b84 | ||
|
|
d171c26755 | ||
|
|
2a23a038e3 | ||
|
|
724618c9b4 | ||
|
|
f1583bcca4 | ||
|
|
10cbf028f1 | ||
|
|
7047b6b4e3 | ||
|
|
434d5bc1a9 | ||
|
|
84038b5be2 | ||
|
|
a093719bd1 | ||
|
|
9e336404ed | ||
|
|
8c2184b93d | ||
|
|
1d6748bc0c | ||
|
|
9f85cbddd9 | ||
|
|
e536f97b18 | ||
|
|
a187e2b268 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
streamdeck-lets-go
|
||||
/streamdeck-lets-go
|
||||
*.test
|
||||
*.out
|
||||
*.exe
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Wakatron
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -398,6 +412,16 @@ Dependencies:
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
This project builds on the work of the following open-source libraries:
|
||||
|
||||
- **[dh1tw/streamdeck](https://github.com/dh1tw/streamdeck)** (MIT) — Go protocol driver for Elgato Stream Deck devices
|
||||
- **[bearsh/hid](https://github.com/bearsh/hid)** (MIT) — Go USB HID bindings via CGO / libusb
|
||||
- **[disintegration/gift](https://github.com/disintegration/gift)** (MIT) — Go Image Filtering Toolkit
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
_ "image/gif"
|
||||
@@ -14,6 +18,8 @@ import (
|
||||
_ "image/png"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/daemon"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -22,14 +28,15 @@ func main() {
|
||||
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 {
|
||||
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, " serve run web UI only (no deck hardware)\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()
|
||||
@@ -42,13 +49,26 @@ func main() {
|
||||
switch cmd {
|
||||
case "daemon":
|
||||
fs.Parse(args)
|
||||
case "serve":
|
||||
case "switch":
|
||||
fs.Parse(args)
|
||||
*noDeck = true
|
||||
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:])
|
||||
}
|
||||
|
||||
@@ -66,11 +86,12 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
httpEnabled := true
|
||||
if err := Run(ctx, cfg, RunOptions{
|
||||
if err := daemon.Run(ctx, cfg, daemon.RunOptions{
|
||||
ConfigPath: *configPath,
|
||||
HTTPAddr: *httpAddr,
|
||||
HTTPEnabled: httpEnabled,
|
||||
NoDeck: *noDeck,
|
||||
StartPage: *startPage,
|
||||
}); err != nil {
|
||||
slog.Error("run", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -78,7 +99,7 @@ func main() {
|
||||
}
|
||||
|
||||
func discover() {
|
||||
devices, err := EnumerateDevices()
|
||||
devices, err := deck.EnumerateDevices()
|
||||
if err != nil {
|
||||
slog.Error("discover", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -92,3 +113,25 @@ func discover() {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
@@ -264,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)
|
||||
@@ -306,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)
|
||||
@@ -322,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)
|
||||
@@ -338,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)
|
||||
@@ -357,6 +362,7 @@ type AutoSwitchManager struct {
|
||||
lastManualPage string
|
||||
autoPage string
|
||||
autoStay bool
|
||||
paused atomic.Bool // disables auto-switch when device is disconnected
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
@@ -410,6 +416,25 @@ func (m *AutoSwitchManager) NotifyManualPage(name string) {
|
||||
slog.Debug("auto-switch: manual page set", "page", name)
|
||||
}
|
||||
|
||||
// Pause disables auto-switch evaluation — use when the device is disconnected
|
||||
// to prevent unnecessary page switches on a closed deck.
|
||||
func (m *AutoSwitchManager) Pause() {
|
||||
m.paused.Store(true)
|
||||
slog.Debug("auto-switch: paused")
|
||||
}
|
||||
|
||||
// Resume re-enables auto-switch evaluation after reconnect.
|
||||
// Stale window events should be drained from the channel before resuming.
|
||||
func (m *AutoSwitchManager) Resume() {
|
||||
m.paused.Store(false)
|
||||
slog.Debug("auto-switch: resumed")
|
||||
}
|
||||
|
||||
// IsPaused returns true when auto-switch evaluation is paused.
|
||||
func (m *AutoSwitchManager) IsPaused() bool {
|
||||
return m.paused.Load()
|
||||
}
|
||||
|
||||
func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -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 {
|
||||
@@ -15,10 +18,11 @@ type RunOptions struct {
|
||||
HTTPAddr string
|
||||
HTTPEnabled bool
|
||||
NoDeck bool
|
||||
StartPage string
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -37,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
|
||||
}
|
||||
@@ -53,16 +57,26 @@ 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]
|
||||
primaryDeck := decks[0]
|
||||
|
||||
startPage := opts.StartPage
|
||||
if startPage == "" {
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
if !pageExists(cfg.Pages, startPage) {
|
||||
slog.Warn("start page not found in pages, falling back to default",
|
||||
"requested", startPage, "default", cfg.DefaultPage)
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, d := range decks {
|
||||
d.Close()
|
||||
@@ -78,10 +92,10 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
web.SetExtraPageManagers(pageMgrs[1:])
|
||||
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||
slog.Warn("activate default page", "error", err)
|
||||
if err := pm.ActivatePage(startPage); err != nil {
|
||||
slog.Warn("activate start page", "error", err)
|
||||
}
|
||||
pm.startPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
|
||||
var windowCh <-chan Window
|
||||
@@ -98,7 +112,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
|
||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
|
||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage)
|
||||
|
||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||
|
||||
@@ -116,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 {
|
||||
@@ -125,7 +139,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
}
|
||||
})
|
||||
|
||||
reconnectTicker := time.NewTicker(5 * time.Second)
|
||||
reconnectTicker := time.NewTicker(2 * time.Second)
|
||||
defer reconnectTicker.Stop()
|
||||
|
||||
ssTicker := time.NewTicker(5 * time.Second)
|
||||
@@ -143,22 +157,34 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
case evt, ok := <-primaryDeck.Events():
|
||||
if !ok {
|
||||
slog.Warn("deck event channel closed, attempting reconnect...")
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
continue
|
||||
}
|
||||
|
||||
wasSsActive := ssCtrl.IsActive()
|
||||
ssCtrl.NotifyInput()
|
||||
|
||||
if evt.Kind == EventKeyPressed {
|
||||
if evt.Kind == deck.EventKeyPressed {
|
||||
if wasSsActive {
|
||||
ssCtrl.Deactivate(primaryDeck)
|
||||
|
||||
@@ -184,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)
|
||||
@@ -194,13 +220,20 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
}
|
||||
|
||||
case win := <-windowCh:
|
||||
if asm.IsPaused() {
|
||||
// Drain buffered window events accumulated while disconnected.
|
||||
for len(windowCh) > 0 {
|
||||
<-windowCh
|
||||
}
|
||||
continue
|
||||
}
|
||||
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
|
||||
for _, pm := range pageMgrs {
|
||||
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)
|
||||
}
|
||||
@@ -229,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)
|
||||
@@ -253,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() {
|
||||
@@ -263,15 +296,27 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
case <-reconnectTicker.C:
|
||||
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
|
||||
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
}
|
||||
|
||||
case <-ssTicker.C:
|
||||
@@ -322,23 +367,32 @@ func deviceBrightness(cfg *config.Config, serial string) int {
|
||||
return 75
|
||||
}
|
||||
|
||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *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(cfg.DefaultPage)
|
||||
(*pm).startPeriodicKeys()
|
||||
asm.NotifyManualPage(cfg.DefaultPage)
|
||||
(*pm).ActivatePage(startPage)
|
||||
(*pm).StartPeriodicKeys()
|
||||
return newDeck
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(3 * time.Second):
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pageExists reports whether pages contains a page with the given name.
|
||||
func pageExists(pages []config.PageConfig, name string) bool {
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package deck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -34,16 +34,16 @@ 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"},
|
||||
{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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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,4 +1,4 @@
|
||||
package main
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -313,7 +325,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
||||
}
|
||||
slog.Warn("invalid background color", "value", k.Background, "error", err)
|
||||
}
|
||||
if err := pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize); err != nil {
|
||||
if err := pm.deck.WriteText(idx, k.Label, color.RGBA{0x21, 0x25, 0x2b, 0xff}, fontName, fontSize); err != nil {
|
||||
slog.Warn("write text", "error", err)
|
||||
}
|
||||
} else if k.Background != "" {
|
||||
@@ -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"
|
||||
}
|
||||
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
|
||||
return cachedConfigDir
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func composeImageWithLabel(src image.Image, text string, keySize int, fontSize f
|
||||
}
|
||||
|
||||
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
|
||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA)
|
||||
|
||||
if text != "" {
|
||||
face, err := parseDisplayFace(fontSize)
|
||||
@@ -181,8 +181,8 @@ document.addEventListener('alpine:init', () => {
|
||||
hasDisplay: !!(kc?.display),
|
||||
hasAction: !!(kc?.actions?.length),
|
||||
actionTypes: this.getActionTypes(kc?.actions || []),
|
||||
displayBg: dout?.background || '',
|
||||
displayText: dout?.text || '',
|
||||
displayBg: (kc?.display && dout?.background) || '',
|
||||
displayText: (kc?.display && dout?.text) || '',
|
||||
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
||||
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
|
||||
})
|
||||
@@ -204,7 +204,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const k = kc || {}
|
||||
let url = '/api/render?key_size=72'
|
||||
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||
const label = dout?.text || this.displayOutputs[k.index]?.text || k.label
|
||||
const label = (k.display && (dout?.text || this.displayOutputs[k.index]?.text)) || k.label
|
||||
if (label) url += `&label=${encodeURIComponent(label)}`
|
||||
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
|
||||
if (k.font_size != null) url += `&font_size=${k.font_size}`
|
||||
@@ -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
|
||||
@@ -172,6 +174,14 @@ func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
s.sseClients[ch] = struct{}{}
|
||||
s.sseMu.Unlock()
|
||||
|
||||
s.mu.RLock()
|
||||
if s.pm != nil {
|
||||
if active := s.pm.ActivePageName(); active != "" {
|
||||
ch <- active
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
notify := r.Context().Done()
|
||||
go func() {
|
||||
<-notify
|
||||
@@ -208,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 {
|
||||
@@ -218,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,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")
|
||||
@@ -479,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
|
||||
@@ -488,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() {
|
||||
@@ -597,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,
|
||||
@@ -616,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())
|
||||
}
|
||||
@@ -653,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,23 @@
|
||||
pkgbase = streamdeck-lets-go-git
|
||||
pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI
|
||||
pkgver = 0.1.0
|
||||
pkgrel = 3
|
||||
url = https://github.com/totmin/streamdeck-lets-go
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
makedepends = go>=1.26
|
||||
makedepends = git
|
||||
depends = libusb
|
||||
optdepends = librsvg: SVG icon rendering
|
||||
optdepends = fontconfig: system font detection
|
||||
provides = streamdeck-lets-go
|
||||
conflicts = streamdeck-lets-go
|
||||
source = streamdeck-lets-go-git::git+https://github.com/totmin/streamdeck-lets-go.git
|
||||
source = streamdeck-lets-go.service
|
||||
source = 90-streamdeck.rules
|
||||
b2sums = SKIP
|
||||
b2sums = SKIP
|
||||
b2sums = SKIP
|
||||
|
||||
pkgname = streamdeck-lets-go-git
|
||||
@@ -0,0 +1,7 @@
|
||||
# Elgato Stream Deck — allow access for locally logged-in users
|
||||
# Vendor 0fd9
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess"
|
||||
@@ -0,0 +1,48 @@
|
||||
# Maintainer: totmin <totmin@users.noreply.github.com>
|
||||
|
||||
pkgname=streamdeck-lets-go-git
|
||||
_giturl=https://github.com/totmin/streamdeck-lets-go.git
|
||||
pkgver=0.1.0
|
||||
pkgrel=3
|
||||
pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://github.com/totmin/streamdeck-lets-go"
|
||||
license=('MIT')
|
||||
depends=('libusb')
|
||||
optdepends=('librsvg: SVG icon rendering'
|
||||
'fontconfig: system font detection')
|
||||
makedepends=('go>=1.26' 'git')
|
||||
provides=('streamdeck-lets-go')
|
||||
conflicts=('streamdeck-lets-go')
|
||||
|
||||
source=("${pkgname}::git+${_giturl}"
|
||||
"streamdeck-lets-go.service"
|
||||
"90-streamdeck.rules")
|
||||
b2sums=('SKIP'
|
||||
'SKIP'
|
||||
'SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "${srcdir}/${pkgname}"
|
||||
git describe --always --tags 2>/dev/null || echo "r0.$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}"
|
||||
export CGO_ENABLED=1
|
||||
go build \
|
||||
-ldflags "-s -w" \
|
||||
-o streamdeck-lets-go ./cmd/streamdeck-lets-go
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}"
|
||||
|
||||
install -Dm755 streamdeck-lets-go "${pkgdir}/usr/bin/streamdeck-lets-go"
|
||||
|
||||
install -Dm644 "${srcdir}/streamdeck-lets-go.service" \
|
||||
"${pkgdir}/usr/lib/systemd/user/streamdeck-lets-go.service"
|
||||
|
||||
install -Dm644 "${srcdir}/90-streamdeck.rules" \
|
||||
"${pkgdir}/usr/lib/udev/rules.d/90-streamdeck.rules"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Stream Deck Lets Go daemon
|
||||
Documentation=https://github.com/Wakatron/streamdeck-lets-go
|
||||
After=graphical-session.target
|
||||
Wants=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/streamdeck-lets-go daemon
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in New Issue
Block a user