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:
+34
-19
@@ -11,16 +11,16 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Version int `json:"version"`
|
||||
LogLevel string `json:"log_level"`
|
||||
DefaultPage string `json:"default_page"`
|
||||
Font string `json:"font,omitempty"`
|
||||
ShowLabelBackground bool `json:"show_label_background"`
|
||||
Devices []DeviceConfig `json:"devices"`
|
||||
Pages []PageConfig `json:"pages"`
|
||||
AutoSwitch []SwitchRule `json:"auto_switch"`
|
||||
Screensaver ScreensaverCfg `json:"screensaver"`
|
||||
Timing TimingConfig `json:"timing,omitempty"`
|
||||
Version int `json:"version"`
|
||||
LogLevel string `json:"log_level"`
|
||||
DefaultPage string `json:"default_page"`
|
||||
Font string `json:"font,omitempty"`
|
||||
ShowLabelBackground bool `json:"show_label_background"`
|
||||
Devices []DeviceConfig `json:"devices"`
|
||||
Pages []PageConfig `json:"pages"`
|
||||
AutoSwitch []SwitchRule `json:"auto_switch"`
|
||||
Screensaver ScreensaverCfg `json:"screensaver"`
|
||||
Timing TimingConfig `json:"timing,omitempty"`
|
||||
}
|
||||
|
||||
type TimingConfig struct {
|
||||
@@ -57,15 +57,15 @@ type DynamicKeyGen struct {
|
||||
}
|
||||
|
||||
type KeyConfig struct {
|
||||
Index int `json:"index"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Background string `json:"background,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Font string `json:"font,omitempty"`
|
||||
FontSize *float64 `json:"font_size,omitempty"`
|
||||
IconScale *float64 `json:"icon_scale,omitempty"`
|
||||
Actions []KeyAction `json:"actions,omitempty"`
|
||||
Display *DisplayCfg `json:"display,omitempty"`
|
||||
Index int `json:"index"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Background string `json:"background,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Font string `json:"font,omitempty"`
|
||||
FontSize *float64 `json:"font_size,omitempty"`
|
||||
IconScale *float64 `json:"icon_scale,omitempty"`
|
||||
Actions []KeyAction `json:"actions,omitempty"`
|
||||
Display *DisplayCfg `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
type KeyAction struct {
|
||||
@@ -151,6 +151,21 @@ func ConfigPath(path string) string {
|
||||
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
|
||||
}
|
||||
|
||||
var cachedConfigDir string
|
||||
|
||||
func ConfigDir() string {
|
||||
if cachedConfigDir != "" {
|
||||
return cachedConfigDir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
cachedConfigDir = "."
|
||||
return cachedConfigDir
|
||||
}
|
||||
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
|
||||
return cachedConfigDir
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
path = ConfigPath(path)
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("execute action", "type", a.Type)
|
||||
|
||||
switch a.Type {
|
||||
case "command":
|
||||
return execCommand(a)
|
||||
case "builtin":
|
||||
return execBuiltin(a, deck, pm)
|
||||
case "script":
|
||||
return execScript(a)
|
||||
case "page":
|
||||
if pm != nil {
|
||||
return pm.ActivatePage(a.Page)
|
||||
}
|
||||
return fmt.Errorf("page manager not available")
|
||||
case "keyboard":
|
||||
return execKeyboard(a)
|
||||
default:
|
||||
return fmt.Errorf("unknown action type: %s", a.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func execCommand(a *config.Action) error {
|
||||
cmd := exec.Command("sh", "-c", a.Command)
|
||||
|
||||
if a.Background {
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
slog.Warn("command finished", "cmd", a.Command, "error", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("run command: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execScript(a *config.Action) error {
|
||||
cmd := exec.Command(a.Script)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("run script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
category := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
slog.Debug("builtin", "category", category, "action", action)
|
||||
|
||||
switch category {
|
||||
case "media":
|
||||
return mediaAction(action)
|
||||
case "volume":
|
||||
return volumeAction(action)
|
||||
case "brightness":
|
||||
return brightnessAction(action)
|
||||
case "page":
|
||||
if pm != nil {
|
||||
return pageBuiltinAction(action, pm)
|
||||
}
|
||||
return fmt.Errorf("page manager not available")
|
||||
case "deck":
|
||||
return deckBuiltinAction(action, deck)
|
||||
default:
|
||||
return fmt.Errorf("unknown builtin category: %s", category)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "playpause":
|
||||
cmd = "playerctl play-pause"
|
||||
case "next":
|
||||
cmd = "playerctl next"
|
||||
case "prev":
|
||||
cmd = "playerctl previous"
|
||||
case "stop":
|
||||
cmd = "playerctl stop"
|
||||
default:
|
||||
return fmt.Errorf("unknown media action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func volumeAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "up":
|
||||
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"
|
||||
case "down":
|
||||
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
|
||||
case "mute":
|
||||
cmd = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||
default:
|
||||
return fmt.Errorf("unknown volume action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func brightnessAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "up":
|
||||
cmd = "brightnessctl s +10%"
|
||||
case "down":
|
||||
cmd = "brightnessctl s 10%-"
|
||||
default:
|
||||
return fmt.Errorf("unknown brightness action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func pageBuiltinAction(action string, pm *render.PageManager) error {
|
||||
switch action {
|
||||
case "next":
|
||||
pageNames := pm.GetPageNames()
|
||||
current := pm.ActivePageName()
|
||||
for i, name := range pageNames {
|
||||
if name == current {
|
||||
next := (i + 1) % len(pageNames)
|
||||
return pm.ActivatePage(pageNames[next])
|
||||
}
|
||||
}
|
||||
if len(pageNames) > 0 {
|
||||
return pm.ActivatePage(pageNames[0])
|
||||
}
|
||||
case "prev":
|
||||
pageNames := pm.GetPageNames()
|
||||
current := pm.ActivePageName()
|
||||
for i, name := range pageNames {
|
||||
if name == current {
|
||||
prev := (i - 1 + len(pageNames)) % len(pageNames)
|
||||
return pm.ActivatePage(pageNames[prev])
|
||||
}
|
||||
}
|
||||
if len(pageNames) > 0 {
|
||||
return pm.ActivatePage(pageNames[0])
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown page action: %s", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deckBuiltinAction(action string, deck *deck.Deck) error {
|
||||
switch action {
|
||||
case "brightness-up":
|
||||
current := deck.Brightness()
|
||||
next := current + 25
|
||||
if next > 100 {
|
||||
next = 25
|
||||
}
|
||||
return deck.SetBrightness(next)
|
||||
case "brightness-down":
|
||||
current := deck.Brightness()
|
||||
next := current - 25
|
||||
if next < 25 {
|
||||
next = 100
|
||||
}
|
||||
return deck.SetBrightness(next)
|
||||
default:
|
||||
return fmt.Errorf("unknown deck action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardTool() string {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
if _, err := exec.LookPath("ydotool"); err == nil {
|
||||
return "ydotool"
|
||||
}
|
||||
if _, err := exec.LookPath("wtype"); err == nil {
|
||||
return "wtype"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if _, err := exec.LookPath("xdotool"); err == nil {
|
||||
return "xdotool"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func execKeyboard(a *config.Action) error {
|
||||
tool := keyboardTool()
|
||||
if tool == "" {
|
||||
wayland := os.Getenv("WAYLAND_DISPLAY") != ""
|
||||
if wayland {
|
||||
return fmt.Errorf("keyboard action requires wtype — install it (e.g. apk add wtype) and restart")
|
||||
}
|
||||
return fmt.Errorf("keyboard action requires xdotool — install it (e.g. apk add xdotool) and restart")
|
||||
}
|
||||
|
||||
keys := strings.ToLower(strings.TrimSpace(a.Keys))
|
||||
if keys == "" {
|
||||
return fmt.Errorf("keyboard action: keys is empty")
|
||||
}
|
||||
|
||||
parts := strings.Split(keys, "+")
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("keyboard action: invalid keys format %q", a.Keys)
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case "ydotool":
|
||||
return execYDOTool(keys)
|
||||
case "wtype":
|
||||
return execWType(parts)
|
||||
case "xdotool":
|
||||
return execXDoTool(keys)
|
||||
default:
|
||||
return fmt.Errorf("keyboard action: unsupported tool %q", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func execWType(parts []string) error {
|
||||
mainKey := parts[len(parts)-1]
|
||||
mods := parts[:len(parts)-1]
|
||||
|
||||
args := make([]string, 0, 2+len(mods)*2)
|
||||
|
||||
for _, m := range mods {
|
||||
args = append(args, "-M", m)
|
||||
}
|
||||
args = append(args, "-P", mainKey, "-p", mainKey)
|
||||
for i := len(mods) - 1; i >= 0; i-- {
|
||||
args = append(args, "-m", mods[i])
|
||||
}
|
||||
|
||||
cmd := exec.Command("wtype", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func execXDoTool(keys string) error {
|
||||
cmd := exec.Command("xdotool", "key", keys)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func execYDOTool(keys string) error {
|
||||
// Use 'type' command instead of 'key' for better compatibility with Wine/games on Wayland
|
||||
cmd := exec.Command("ydotool", "type", keys)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
type WindowDetector interface {
|
||||
Start(ctx context.Context) (<-chan Window, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type Window struct {
|
||||
WMClass string
|
||||
Title string
|
||||
}
|
||||
|
||||
func NewWindowDetector() WindowDetector {
|
||||
desktop := os.Getenv("XDG_CURRENT_DESKTOP")
|
||||
wayland := os.Getenv("WAYLAND_DISPLAY")
|
||||
|
||||
slog.Debug("detecting desktop environment", "XDG_CURRENT_DESKTOP", desktop, "WAYLAND_DISPLAY", wayland)
|
||||
|
||||
switch {
|
||||
case desktop == "Hyprland":
|
||||
slog.Info("auto-switch: using Hyprland detector")
|
||||
d := &hyprlandDetector{}
|
||||
d.PollInterval = 200 * time.Millisecond
|
||||
return d
|
||||
|
||||
case desktop == "sway":
|
||||
slog.Info("auto-switch: using Sway detector")
|
||||
d := &swayDetector{}
|
||||
d.PollInterval = 500 * time.Millisecond
|
||||
return d
|
||||
|
||||
case desktop == "niri":
|
||||
slog.Info("auto-switch: using Niri detector")
|
||||
d := &niriDetector{}
|
||||
d.PollInterval = 200 * time.Millisecond
|
||||
return d
|
||||
|
||||
case desktop == "GNOME" || strings.Contains(desktop, "GNOME"):
|
||||
slog.Info("auto-switch: GNOME detector (stub)")
|
||||
d := &gnomeDetector{}
|
||||
d.PollInterval = 500 * time.Millisecond
|
||||
return d
|
||||
|
||||
case strings.Contains(desktop, "KDE") || strings.Contains(desktop, "plasma"):
|
||||
slog.Info("auto-switch: KDE detector (stub)")
|
||||
d := &kdeDetector{}
|
||||
d.PollInterval = 500 * time.Millisecond
|
||||
return d
|
||||
|
||||
case wayland != "":
|
||||
slog.Warn("auto-switch: unknown Wayland compositor, using portal fallback")
|
||||
d := &portalDetector{}
|
||||
d.PollInterval = 500 * time.Millisecond
|
||||
return d
|
||||
|
||||
default:
|
||||
slog.Info("auto-switch: using X11 detector")
|
||||
d := &x11Detector{}
|
||||
d.PollInterval = 200 * time.Millisecond
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
type basePoll struct {
|
||||
PollInterval time.Duration
|
||||
pollFn func(context.Context) Window
|
||||
cancel context.CancelFunc
|
||||
lastRaw string
|
||||
}
|
||||
|
||||
func (b *basePoll) start(ctx context.Context, ch chan<- Window) {
|
||||
ticker := time.NewTicker(b.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
win := b.pollFn(ctx)
|
||||
raw := win.WMClass + "|" + win.Title
|
||||
if raw != b.lastRaw {
|
||||
b.lastRaw = raw
|
||||
select {
|
||||
case ch <- win:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *basePoll) stop() {
|
||||
if b.cancel != nil {
|
||||
b.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func runCmd(ctx context.Context, cmd string, args ...string) (string, error) {
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
out, err := c.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func runCmdJSON(ctx context.Context, cmd string, args ...string) ([]byte, error) {
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
return c.Output()
|
||||
}
|
||||
|
||||
type hyprlandDetector struct {
|
||||
basePoll
|
||||
}
|
||||
|
||||
func (d *hyprlandDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (d *hyprlandDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
type hyprlandWindow struct {
|
||||
Class string `json:"class"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (d *hyprlandDetector) getWindow(ctx context.Context) Window {
|
||||
data, err := runCmdJSON(ctx, "hyprctl", "activewindow", "-j")
|
||||
if err != nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
var win hyprlandWindow
|
||||
if err := json.Unmarshal(data, &win); err != nil {
|
||||
return Window{}
|
||||
}
|
||||
return Window{WMClass: win.Class, Title: win.Title}
|
||||
}
|
||||
|
||||
type swayDetector struct {
|
||||
basePoll
|
||||
}
|
||||
|
||||
func (d *swayDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (d *swayDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
type swayNode struct {
|
||||
Type string `json:"type"`
|
||||
Focused bool `json:"focused"`
|
||||
AppID *string `json:"app_id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []swayNode `json:"nodes"`
|
||||
Floating []swayNode `json:"floating_nodes"`
|
||||
Props *swayWindowProps `json:"window_properties"`
|
||||
}
|
||||
|
||||
type swayWindowProps struct {
|
||||
Class string `json:"class"`
|
||||
}
|
||||
|
||||
func (d *swayDetector) getWindow(ctx context.Context) Window {
|
||||
data, err := runCmdJSON(ctx, "swaymsg", "-t", "get_tree")
|
||||
if err != nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
var root swayNode
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node == nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
wmClass := ""
|
||||
if node.AppID != nil {
|
||||
wmClass = *node.AppID
|
||||
} else if node.Props != nil {
|
||||
wmClass = node.Props.Class
|
||||
}
|
||||
|
||||
return Window{WMClass: wmClass, Title: node.Name}
|
||||
}
|
||||
|
||||
func findFocusedSwayNode(node *swayNode) *swayNode {
|
||||
if node.Type == "con" && node.Focused {
|
||||
return node
|
||||
}
|
||||
for i := range node.Nodes {
|
||||
if found := findFocusedSwayNode(&node.Nodes[i]); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
for i := range node.Floating {
|
||||
if found := findFocusedSwayNode(&node.Floating[i]); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type niriDetector struct {
|
||||
basePoll
|
||||
}
|
||||
|
||||
func (d *niriDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (d *niriDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
type niriWindow struct {
|
||||
AppID *string `json:"app_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (d *niriDetector) getWindow(ctx context.Context) Window {
|
||||
data, err := runCmdJSON(ctx, "niri", "msg", "--json", "focused-window")
|
||||
if err != nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
var win niriWindow
|
||||
if err := json.Unmarshal(data, &win); err != nil {
|
||||
return Window{}
|
||||
}
|
||||
if win.AppID == nil {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
return Window{WMClass: *win.AppID, Title: win.Title}
|
||||
}
|
||||
|
||||
type gnomeDetector struct{ basePoll }
|
||||
|
||||
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (d *gnomeDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
func (d *gnomeDetector) getWindow(ctx context.Context) Window {
|
||||
rawClass, err := runCmd(ctx, "gdbus", "call", "--session",
|
||||
"--dest", "org.gnome.Shell",
|
||||
"--object-path", "/org/gnome/Shell",
|
||||
"--method", "org.gnome.Shell.Eval",
|
||||
"global.display.focus_window?.get_wm_class() ?? ''")
|
||||
if err != nil {
|
||||
return Window{}
|
||||
}
|
||||
rawTitle, err := runCmd(ctx, "gdbus", "call", "--session",
|
||||
"--dest", "org.gnome.Shell",
|
||||
"--object-path", "/org/gnome/Shell",
|
||||
"--method", "org.gnome.Shell.Eval",
|
||||
"global.display.focus_window?.title ?? ''")
|
||||
if err != nil {
|
||||
_ = rawClass
|
||||
return Window{}
|
||||
}
|
||||
|
||||
wmClass := parseGnomeEval(rawClass)
|
||||
title := parseGnomeEval(rawTitle)
|
||||
return Window{WMClass: wmClass, Title: title}
|
||||
}
|
||||
|
||||
func parseGnomeEval(out string) string {
|
||||
out = strings.TrimSpace(out)
|
||||
out = strings.TrimPrefix(out, "(true, ")
|
||||
out = strings.TrimSuffix(out, ")")
|
||||
out = strings.Trim(out, `"'`)
|
||||
return out
|
||||
}
|
||||
|
||||
type kdeDetector struct{ basePoll }
|
||||
|
||||
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (d *kdeDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
func (d *kdeDetector) getWindow(ctx context.Context) Window {
|
||||
wmClass, _ := runCmd(ctx, "kdotool", "getactivewindow", "getclassname")
|
||||
title, _ := runCmd(ctx, "kdotool", "getactivewindow", "getwindowname")
|
||||
return Window{WMClass: wmClass, Title: title}
|
||||
}
|
||||
|
||||
type x11Detector struct{ basePoll }
|
||||
|
||||
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (d *x11Detector) Close() { d.basePoll.stop() }
|
||||
|
||||
func (d *x11Detector) getWindow(ctx context.Context) Window {
|
||||
wmClass, _ := runCmd(ctx, "xdotool", "getactivewindow", "getclassname")
|
||||
title, _ := runCmd(ctx, "xdotool", "getactivewindow", "getwindowname")
|
||||
return Window{WMClass: wmClass, Title: title}
|
||||
}
|
||||
|
||||
type portalDetector struct{ basePoll }
|
||||
|
||||
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||
d.pollFn = d.getWindow
|
||||
ctx, d.cancel = context.WithCancel(ctx)
|
||||
ch := make(chan Window, 4)
|
||||
go d.basePoll.start(ctx, ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (d *portalDetector) Close() { d.basePoll.stop() }
|
||||
|
||||
func (d *portalDetector) getWindow(ctx context.Context) Window {
|
||||
return Window{}
|
||||
}
|
||||
|
||||
type AutoSwitchManager struct {
|
||||
mu sync.RWMutex
|
||||
rules []compiledRule
|
||||
lastManualPage string
|
||||
autoPage string
|
||||
autoStay bool
|
||||
paused atomic.Bool // disables auto-switch when device is disconnected
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
wmClass *regexp.Regexp
|
||||
title *regexp.Regexp
|
||||
rule config.SwitchRule
|
||||
}
|
||||
|
||||
func NewAutoSwitchManager(rules []config.SwitchRule, defaultPage string) *AutoSwitchManager {
|
||||
m := &AutoSwitchManager{
|
||||
lastManualPage: defaultPage,
|
||||
}
|
||||
m.Reload(rules)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AutoSwitchManager) Reload(rules []config.SwitchRule) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.rules = make([]compiledRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
cr := compiledRule{rule: r}
|
||||
if r.WMClass != "" {
|
||||
re, err := regexp.Compile(r.WMClass)
|
||||
if err != nil {
|
||||
slog.Warn("auto-switch: invalid wm_class regex", "pattern", r.WMClass, "error", err)
|
||||
continue
|
||||
}
|
||||
cr.wmClass = re
|
||||
}
|
||||
if r.Title != "" {
|
||||
re, err := regexp.Compile(r.Title)
|
||||
if err != nil {
|
||||
slog.Warn("auto-switch: invalid title regex", "pattern", r.Title, "error", err)
|
||||
continue
|
||||
}
|
||||
cr.title = re
|
||||
}
|
||||
m.rules = append(m.rules, cr)
|
||||
}
|
||||
|
||||
slog.Info("auto-switch: rules loaded", "count", len(m.rules))
|
||||
}
|
||||
|
||||
func (m *AutoSwitchManager) NotifyManualPage(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lastManualPage = name
|
||||
m.autoPage = ""
|
||||
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()
|
||||
|
||||
if win.WMClass == "" && win.Title == "" {
|
||||
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
|
||||
m.autoPage = ""
|
||||
slog.Debug("auto-switch: no focused window, reverting to manual page", "page", m.lastManualPage)
|
||||
return m.lastManualPage, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, r := range m.rules {
|
||||
if r.wmClass == nil && r.title == nil {
|
||||
continue
|
||||
}
|
||||
if r.wmClass != nil && !r.wmClass.MatchString(win.WMClass) {
|
||||
continue
|
||||
}
|
||||
if r.title != nil && !r.title.MatchString(win.Title) {
|
||||
continue
|
||||
}
|
||||
|
||||
m.autoPage = r.rule.Page
|
||||
m.autoStay = r.rule.Stay
|
||||
slog.Debug("auto-switch: rule matched",
|
||||
"wm_class", win.WMClass, "page", r.rule.Page, "stay", r.rule.Stay)
|
||||
return r.rule.Page, true
|
||||
}
|
||||
|
||||
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
|
||||
m.autoPage = ""
|
||||
slog.Debug("auto-switch: reverting to manual page", "page", m.lastManualPage)
|
||||
return m.lastManualPage, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
func TestFindFocusedSwayNode(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"name": "eDP-1",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"name": "1",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": false,
|
||||
"app_id": "firefox",
|
||||
"name": "Firefox",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "workspace",
|
||||
"name": "2",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": true,
|
||||
"app_id": "Alacritty",
|
||||
"name": "Alacritty",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"floating_nodes": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
if err := json.Unmarshal([]byte(input), &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node == nil {
|
||||
t.Fatal("expected focused node, got nil")
|
||||
}
|
||||
if node.AppID == nil || *node.AppID != "Alacritty" {
|
||||
t.Fatalf("expected Alacritty, got %v", node.AppID)
|
||||
}
|
||||
if node.Name != "Alacritty" {
|
||||
t.Fatalf("expected name Alacritty, got %s", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFocusedSwayNode_XWayland(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": true,
|
||||
"app_id": null,
|
||||
"name": "Steam",
|
||||
"window_properties": {"class": "steam"},
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
json.Unmarshal([]byte(input), &root)
|
||||
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node == nil {
|
||||
t.Fatal("expected focused node, got nil")
|
||||
}
|
||||
if node.Props == nil || node.Props.Class != "steam" {
|
||||
t.Fatal("expected window_properties.class = steam")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFocusedSwayNode_NoFocus(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": false,
|
||||
"app_id": "kitty",
|
||||
"name": "kitty",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
json.Unmarshal([]byte(input), &root)
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node != nil {
|
||||
t.Fatal("expected nil when no focused node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_Evaluate(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser"},
|
||||
{WMClass: "Alacritty|kitty", Title: ".*vim.*", Page: "coding", Stay: true},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "default")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "default")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
page, ok = m.Evaluate(Window{WMClass: "Alacritty", Title: "nvim main.go"}, "default")
|
||||
if !ok || page != "coding" {
|
||||
t.Fatalf("expected coding, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "YouTube"}, "browser")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser again, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_Stay(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: false},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if !ok || page != "home" {
|
||||
t.Fatalf("expected revert to home, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_StayTrue(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: true},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if ok {
|
||||
t.Fatalf("expected no switch (stay=true), got %s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_ManualOverride(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: false},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
// 1. Firefox opens → auto-switch to "browser"
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
// 2. User manually switches to "settings"
|
||||
m.NotifyManualPage("settings")
|
||||
|
||||
// 3. Window changes to thunar on "settings" → no match → stay on settings
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "settings")
|
||||
if ok {
|
||||
t.Fatalf("expected no switch (already on manual page), got %s", page)
|
||||
}
|
||||
|
||||
// 4. Firefox comes back → auto-switch to "browser" again
|
||||
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "settings")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
// 5. Thunar again, current page is "browser" (auto) → no match → revert to "settings"
|
||||
page, ok = m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if !ok || page != "settings" {
|
||||
t.Fatalf("expected revert to settings, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
"streamdeck-lets-go/internal/web"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
ConfigPath string
|
||||
HTTPAddr string
|
||||
HTTPEnabled bool
|
||||
NoDeck bool
|
||||
StartPage string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
web := web.NewWebServer(cfg, opts.ConfigPath)
|
||||
|
||||
if !opts.NoDeck || opts.HTTPEnabled {
|
||||
checkKeyboardTool()
|
||||
}
|
||||
|
||||
if opts.HTTPEnabled {
|
||||
go func() {
|
||||
if err := web.Serve(ctx, opts.HTTPAddr); err != nil {
|
||||
slog.Error("web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.NoDeck {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
var decks []*deck.Deck
|
||||
var err error
|
||||
|
||||
for {
|
||||
decks, err = deck.OpenAllDecks()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
slog.Warn("no stream deck found, retrying in 5s", "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
pageMgrs := make([]*render.PageManager, len(decks))
|
||||
for i, d := range decks {
|
||||
pageMgrs[i] = render.NewPageManager(d)
|
||||
pageMgrs[i].DefaultFont = cfg.Font
|
||||
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pageMgrs[i].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()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
web.SetExtraPageManagers(pageMgrs[1:])
|
||||
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(startPage); err != nil {
|
||||
slog.Warn("activate start page", "error", err)
|
||||
}
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
|
||||
var windowCh <-chan Window
|
||||
var detector WindowDetector
|
||||
|
||||
defer func() {
|
||||
if detector != nil {
|
||||
detector.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if len(cfg.AutoSwitch) > 0 {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
|
||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage)
|
||||
|
||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||
|
||||
ge := NewGestureEngine(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs, func(idx int, a *config.Action) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
oldPage := primaryPM.ActivePageName()
|
||||
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
|
||||
slog.Error("execute action", "error", err)
|
||||
}
|
||||
newPage := primaryPM.ActivePageName()
|
||||
if newPage != oldPage {
|
||||
if a.Type == "page" {
|
||||
asm.NotifyManualPage(a.Page)
|
||||
}
|
||||
for _, pm := range pageMgrs {
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
web.BroadcastPageChange(newPage)
|
||||
} else {
|
||||
primaryPM.RefreshDisplayKey(idx)
|
||||
}
|
||||
})
|
||||
|
||||
reconnectTicker := time.NewTicker(2 * time.Second)
|
||||
defer reconnectTicker.Stop()
|
||||
|
||||
ssTicker := time.NewTicker(5 * time.Second)
|
||||
defer ssTicker.Stop()
|
||||
|
||||
configTicker := time.NewTicker(3 * time.Second)
|
||||
defer configTicker.Stop()
|
||||
var lastConfigMod time.Time
|
||||
|
||||
slog.Info("daemon started", "decks", len(decks))
|
||||
defer slog.Info("daemon stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
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, 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 == deck.EventKeyPressed {
|
||||
if wasSsActive {
|
||||
ssCtrl.Deactivate(primaryDeck)
|
||||
|
||||
savedOutputs := primaryPM.GetDisplayOutputs()
|
||||
|
||||
if page := primaryPM.ActivePage(); page != nil {
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(primaryPM.ActivePageName()); err != nil {
|
||||
slog.Warn("screensaver: re-render page", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for idx, dout := range savedOutputs {
|
||||
if dout != nil {
|
||||
primaryPM.ReRenderDisplayKey(idx, dout.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page := primaryPM.ActivePage()
|
||||
if page == nil {
|
||||
continue
|
||||
}
|
||||
for _, k := range primaryPM.ActiveKeys() {
|
||||
if k.Index == evt.Index {
|
||||
if len(k.Actions) > 0 {
|
||||
ge.HandleEvent(evt, k.Actions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
web.BroadcastPageChange(page)
|
||||
}
|
||||
|
||||
case <-configTicker.C:
|
||||
path := config.ConfigPath(opts.ConfigPath)
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mt := fi.ModTime()
|
||||
if mt.After(lastConfigMod) && !lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
slog.Info("config file changed, reloading")
|
||||
newCfg, err := config.LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
slog.Error("reload config", "error", err)
|
||||
continue
|
||||
}
|
||||
cfg = newCfg
|
||||
web.UpdateConfig(cfg)
|
||||
ssCtrl = NewScreensaver(&cfg.Screensaver)
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
var activePages []string
|
||||
for _, pm := range pageMgrs {
|
||||
activePages = append(activePages, pm.ActivePageName())
|
||||
pm.StopPeriodicKeys()
|
||||
pm.DefaultFont = cfg.Font
|
||||
pm.ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pm.LoadPages(cfg.Pages)
|
||||
}
|
||||
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
|
||||
asm.Reload(cfg.AutoSwitch)
|
||||
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
for i, pm := range pageMgrs {
|
||||
page := cfg.DefaultPage
|
||||
if i < len(activePages) && activePages[i] != "" {
|
||||
for _, p := range cfg.Pages {
|
||||
if p.Name == activePages[i] {
|
||||
page = activePages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("reload: activate page", "error", err)
|
||||
}
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
}
|
||||
if lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
}
|
||||
|
||||
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, 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:
|
||||
if ssCtrl.Check() {
|
||||
for _, d := range decks {
|
||||
ssCtrl.Activate(d, &cfg.Screensaver)
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkKeyboardTool() {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
// On Wayland, prefer ydotool over wtype
|
||||
if _, err := exec.LookPath("ydotool"); err == nil {
|
||||
// Start ydotoold daemon if not already running
|
||||
cmd := exec.Command("ydotoold")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
// Run in background, don't wait
|
||||
_ = cmd.Start()
|
||||
slog.Info("ydotoold daemon started for keyboard input")
|
||||
} else if _, err := exec.LookPath("wtype"); err == nil {
|
||||
slog.Warn("ydotool not found, falling back to wtype — consider installing ydotool for better Wayland support (e.g. apk add ydotool)")
|
||||
} else {
|
||||
slog.Warn("keyboard actions require ydotool or wtype on Wayland — install one (e.g. apk add ydotool) and restart")
|
||||
}
|
||||
} else {
|
||||
if _, err := exec.LookPath("xdotool"); err != nil {
|
||||
slog.Warn("keyboard actions require xdotool on X11 — install it (e.g. apk add xdotool) and restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceBrightness(cfg *config.Config, serial string) int {
|
||||
for _, d := range cfg.Devices {
|
||||
if d.Serial == serial || d.Serial == "" {
|
||||
if d.Brightness > 0 {
|
||||
return d.Brightness
|
||||
}
|
||||
return 75
|
||||
}
|
||||
}
|
||||
return 75
|
||||
}
|
||||
|
||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck {
|
||||
for {
|
||||
newDeck, err := deck.OpenDeck("")
|
||||
if err == nil {
|
||||
*pm = render.NewPageManager(newDeck)
|
||||
(*pm).DefaultFont = cfg.Font
|
||||
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
|
||||
(*pm).LoadPages(cfg.Pages)
|
||||
(*pm).ActivatePage(startPage)
|
||||
(*pm).StartPeriodicKeys()
|
||||
return newDeck
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
)
|
||||
|
||||
type ActionCallback func(idx int, a *config.Action)
|
||||
|
||||
type gestureKeyState struct {
|
||||
mu sync.Mutex
|
||||
pressedAt time.Time
|
||||
lastTapAt time.Time
|
||||
holdTimer *time.Timer
|
||||
holdActive bool
|
||||
pendingTap *time.Timer
|
||||
}
|
||||
|
||||
type GestureEngine struct {
|
||||
mu sync.Mutex
|
||||
states map[int]*gestureKeyState
|
||||
longMs time.Duration
|
||||
doubleMs time.Duration
|
||||
onAction ActionCallback
|
||||
}
|
||||
|
||||
func NewGestureEngine(longMs, doubleMs int, cb ActionCallback) *GestureEngine {
|
||||
if longMs <= 0 {
|
||||
longMs = config.DefaultLongPressMs
|
||||
}
|
||||
if doubleMs <= 0 {
|
||||
doubleMs = config.DefaultDoubleTapMs
|
||||
}
|
||||
return &GestureEngine{
|
||||
states: make(map[int]*gestureKeyState),
|
||||
longMs: time.Duration(longMs) * time.Millisecond,
|
||||
doubleMs: time.Duration(doubleMs) * time.Millisecond,
|
||||
onAction: cb,
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) ReloadTiming(longMs, doubleMs int) {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
if longMs > 0 {
|
||||
ge.longMs = time.Duration(longMs) * time.Millisecond
|
||||
}
|
||||
if doubleMs > 0 {
|
||||
ge.doubleMs = time.Duration(doubleMs) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) Reset() {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
for _, s := range ge.states {
|
||||
s.mu.Lock()
|
||||
s.cancelTimers()
|
||||
s.holdActive = false
|
||||
s.lastTapAt = time.Time{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gestureKeyState) cancelTimers() {
|
||||
if s.holdTimer != nil {
|
||||
s.holdTimer.Stop()
|
||||
s.holdTimer = nil
|
||||
}
|
||||
if s.pendingTap != nil {
|
||||
s.pendingTap.Stop()
|
||||
s.pendingTap = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) getState(index int) *gestureKeyState {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
s, ok := ge.states[index]
|
||||
if !ok {
|
||||
s = &gestureKeyState{}
|
||||
ge.states[index] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func findAction(actions []config.KeyAction, trigger string) *config.Action {
|
||||
for _, a := range actions {
|
||||
if a.Trigger == trigger {
|
||||
return &a.Action
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) {
|
||||
state := ge.getState(evt.Index)
|
||||
|
||||
if len(actions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch evt.Kind {
|
||||
case deck.EventKeyPressed:
|
||||
ge.handleKeyPress(state, evt, actions)
|
||||
case deck.EventKeyReleased:
|
||||
ge.handleKeyRelease(state, evt, actions)
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
||||
state.mu.Lock()
|
||||
state.cancelTimers()
|
||||
state.holdActive = false
|
||||
state.pressedAt = evt.At
|
||||
state.holdTimer = time.AfterFunc(ge.longMs, func() {
|
||||
state.mu.Lock()
|
||||
state.holdActive = true
|
||||
state.holdTimer = nil
|
||||
state.mu.Unlock()
|
||||
|
||||
if a := findAction(actions, "hold_start"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
} else if a := findAction(actions, "long_press"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
})
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
||||
state.mu.Lock()
|
||||
state.cancelTimers()
|
||||
|
||||
if state.holdActive {
|
||||
state.holdActive = false
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "hold_end"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !state.lastTapAt.IsZero() && evt.At.Sub(state.lastTapAt) <= ge.doubleMs {
|
||||
state.lastTapAt = time.Time{}
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "double_tap"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastTap := evt.At
|
||||
state.lastTapAt = lastTap
|
||||
|
||||
state.pendingTap = time.AfterFunc(ge.doubleMs, func() {
|
||||
state.mu.Lock()
|
||||
if state.lastTapAt.Equal(lastTap) {
|
||||
state.lastTapAt = time.Time{}
|
||||
state.pendingTap = nil
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "tap"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
state.mu.Unlock()
|
||||
})
|
||||
state.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
type Screensaver struct {
|
||||
enabled bool
|
||||
idleAfter time.Duration
|
||||
lastInput time.Time
|
||||
active bool
|
||||
savedBrightness int
|
||||
}
|
||||
|
||||
func NewScreensaver(cfg *config.ScreensaverCfg) *Screensaver {
|
||||
ss := &Screensaver{
|
||||
enabled: cfg.Enabled,
|
||||
idleAfter: time.Duration(cfg.IdleSeconds) * time.Second,
|
||||
lastInput: time.Now(),
|
||||
}
|
||||
if ss.idleAfter <= 0 {
|
||||
ss.idleAfter = 30 * time.Second
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *Screensaver) NotifyInput() {
|
||||
ss.lastInput = time.Now()
|
||||
if ss.active {
|
||||
ss.active = false
|
||||
slog.Debug("screensaver deactivated")
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Check() bool {
|
||||
if !ss.enabled {
|
||||
return false
|
||||
}
|
||||
if time.Since(ss.lastInput) > ss.idleAfter {
|
||||
if !ss.active {
|
||||
ss.active = true
|
||||
slog.Debug("screensaver activated")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
|
||||
ss.savedBrightness = deck.Brightness()
|
||||
|
||||
brightness := cfg.Brightness
|
||||
if brightness <= 0 {
|
||||
brightness = 10
|
||||
}
|
||||
if err := deck.SetBrightness(brightness); err != nil {
|
||||
slog.Warn("screensaver: set brightness", "error", err)
|
||||
}
|
||||
|
||||
if cfg.Image != "" {
|
||||
img, err := render.LoadImage(cfg.Image, 0, 0)
|
||||
if err != nil {
|
||||
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
|
||||
return
|
||||
}
|
||||
if err := deck.FillPanel(img); err != nil {
|
||||
slog.Warn("screensaver: fill panel", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("screensaver activated")
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
|
||||
brightness := ss.savedBrightness
|
||||
if brightness <= 0 {
|
||||
brightness = 75
|
||||
}
|
||||
if err := deck.SetBrightness(brightness); err != nil {
|
||||
slog.Warn("screensaver: restore brightness", "error", err)
|
||||
}
|
||||
ss.savedBrightness = 0
|
||||
slog.Info("screensaver deactivated")
|
||||
}
|
||||
|
||||
func (ss *Screensaver) IsActive() bool {
|
||||
return ss.active
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/bearsh/hid"
|
||||
"github.com/dh1tw/streamdeck"
|
||||
"github.com/disintegration/gift"
|
||||
)
|
||||
|
||||
type DeviceInfo struct {
|
||||
Serial string
|
||||
Model string
|
||||
PID uint16
|
||||
}
|
||||
|
||||
type DeckConfig struct {
|
||||
PID uint16
|
||||
Name string
|
||||
KeysX int
|
||||
KeysY int
|
||||
KeySize int
|
||||
ImageFmt string
|
||||
Rotate bool
|
||||
Convert bool
|
||||
HasDials bool
|
||||
HasTouch bool
|
||||
}
|
||||
|
||||
var KnownDecks = []DeckConfig{
|
||||
{PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true},
|
||||
{PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
||||
{PID: 0x63, Name: "OriginalV2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
|
||||
{PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
||||
{PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
|
||||
}
|
||||
|
||||
func findConfig(pid uint16) (DeckConfig, bool) {
|
||||
for _, d := range KnownDecks {
|
||||
if d.PID == pid {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return DeckConfig{}, false
|
||||
}
|
||||
|
||||
func EnumerateDevices() ([]DeviceInfo, error) {
|
||||
devices := hid.Enumerate(streamdeck.VendorID, 0)
|
||||
if len(devices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var infos []DeviceInfo
|
||||
for _, d := range devices {
|
||||
info := DeviceInfo{Serial: d.Serial, PID: d.ProductID}
|
||||
if cfg, ok := findConfig(d.ProductID); ok {
|
||||
info.Model = cfg.Name
|
||||
} else {
|
||||
info.Model = fmt.Sprintf("Unknown (0x%04x)", d.ProductID)
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func toStreamDeckConfig(dc DeckConfig) *streamdeck.Config {
|
||||
return &streamdeck.Config{
|
||||
ProductID: dc.PID,
|
||||
NumButtonColumns: dc.KeysX,
|
||||
NumButtonRows: dc.KeysY,
|
||||
Spacer: 19,
|
||||
ButtonSize: dc.KeySize,
|
||||
ImageFormat: dc.ImageFmt,
|
||||
ImageRotate: dc.Rotate,
|
||||
ConvertKey: dc.Convert,
|
||||
}
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Kind int
|
||||
Index int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
EventKeyPressed = 1
|
||||
EventKeyReleased = 2
|
||||
)
|
||||
|
||||
type Deck struct {
|
||||
sd *streamdeck.StreamDeck
|
||||
cfg DeckConfig
|
||||
serial string
|
||||
|
||||
mu sync.Mutex
|
||||
events chan Event
|
||||
closed bool
|
||||
brightness int
|
||||
}
|
||||
|
||||
func OpenDeck(serial string) (*Deck, error) {
|
||||
desiredSerial := serial
|
||||
if serial == "first" || serial == "" {
|
||||
desiredSerial = ""
|
||||
}
|
||||
|
||||
sd, err := streamdeck.NewStreamDeck(desiredSerial)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening streamdeck: %w", err)
|
||||
}
|
||||
|
||||
cfg, ok := findConfig(sd.Config.ProductID)
|
||||
if !ok {
|
||||
sd.Close()
|
||||
return nil, fmt.Errorf("unsupported device PID: 0x%04x", sd.Config.ProductID)
|
||||
}
|
||||
|
||||
d := &Deck{
|
||||
sd: sd,
|
||||
cfg: cfg,
|
||||
serial: sd.Serial(),
|
||||
events: make(chan Event, 64),
|
||||
brightness: 75,
|
||||
}
|
||||
|
||||
sd.SetBtnEventCb(func(s streamdeck.State, e streamdeck.Event) {
|
||||
kind := 0
|
||||
switch e.Kind {
|
||||
case streamdeck.EventKeyPressed:
|
||||
kind = EventKeyPressed
|
||||
case streamdeck.EventKeyReleased:
|
||||
kind = EventKeyReleased
|
||||
default:
|
||||
return
|
||||
}
|
||||
select {
|
||||
case d.events <- Event{Kind: kind, Index: e.Which, At: time.Now()}:
|
||||
default:
|
||||
slog.Warn("event channel full, dropping event", "kind", e.Kind, "index", e.Which)
|
||||
}
|
||||
})
|
||||
|
||||
slog.Info("deck opened", "serial", d.serial, "model", cfg.Name, "keys", cfg.KeysX*cfg.KeysY)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Deck) Close() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return
|
||||
}
|
||||
d.closed = true
|
||||
close(d.events)
|
||||
d.sd.Close()
|
||||
}
|
||||
|
||||
func (d *Deck) Serial() string { return d.serial }
|
||||
func (d *Deck) Events() <-chan Event { return d.events }
|
||||
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
|
||||
func (d *Deck) KeySize() int { return d.cfg.KeySize }
|
||||
func (d *Deck) Config() DeckConfig { return d.cfg }
|
||||
func (d *Deck) Model() string { return d.cfg.Name }
|
||||
|
||||
type DeckInfo struct {
|
||||
Serial string `json:"serial"`
|
||||
Model string `json:"model"`
|
||||
KeysX int `json:"keys_x"`
|
||||
KeysY int `json:"keys_y"`
|
||||
NumKeys int `json:"num_keys"`
|
||||
KeySize int `json:"key_size"`
|
||||
}
|
||||
|
||||
func (d *Deck) DeckInfo() DeckInfo {
|
||||
return DeckInfo{
|
||||
Serial: d.serial,
|
||||
Model: d.cfg.Name,
|
||||
KeysX: d.cfg.KeysX,
|
||||
KeysY: d.cfg.KeysY,
|
||||
NumKeys: d.cfg.KeysX * d.cfg.KeysY,
|
||||
KeySize: d.cfg.KeySize,
|
||||
}
|
||||
}
|
||||
|
||||
func OpenAllDecks() ([]*Deck, error) {
|
||||
devices := hid.Enumerate(streamdeck.VendorID, 0)
|
||||
if len(devices) == 0 {
|
||||
return nil, fmt.Errorf("no stream deck devices found")
|
||||
}
|
||||
var decks []*Deck
|
||||
for _, d := range devices {
|
||||
if _, ok := findConfig(d.ProductID); !ok {
|
||||
slog.Warn("skipping unsupported device", "pid", fmt.Sprintf("0x%04x", d.ProductID))
|
||||
continue
|
||||
}
|
||||
deck, err := OpenDeck(d.Serial)
|
||||
if err != nil {
|
||||
slog.Warn("failed to open deck", "serial", d.Serial, "error", err)
|
||||
continue
|
||||
}
|
||||
decks = append(decks, deck)
|
||||
}
|
||||
if len(decks) == 0 {
|
||||
return nil, fmt.Errorf("no supported stream deck devices could be opened")
|
||||
}
|
||||
return decks, nil
|
||||
}
|
||||
|
||||
func (d *Deck) SetBrightness(val int) error {
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 100 {
|
||||
val = 100
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
d.brightness = val
|
||||
return d.sd.SetBrightness(uint16(val))
|
||||
}
|
||||
|
||||
func (d *Deck) Brightness() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.brightness
|
||||
}
|
||||
|
||||
func (d *Deck) FillColor(keyIndex int, r, g, b uint8) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.FillColor(keyIndex, int(r), int(g), int(b))
|
||||
}
|
||||
|
||||
func (d *Deck) FillImage(keyIndex int, img image.Image) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.FillImage(keyIndex, img)
|
||||
}
|
||||
|
||||
func (d *Deck) FillPanel(img image.Image) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.FillPanel(img)
|
||||
}
|
||||
|
||||
func (d *Deck) ClearAll() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.ClearAllBtns()
|
||||
}
|
||||
|
||||
func (d *Deck) ClearKey(idx int) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.ClearBtn(idx)
|
||||
}
|
||||
|
||||
func (d *Deck) WriteText(keyIndex int, text string, bg color.Color, fontName string, fontSize float64) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
if fontSize <= 0 {
|
||||
fontSize = 18
|
||||
}
|
||||
font := streamdeck.MonoMedium
|
||||
if fontName == "regular" {
|
||||
font = streamdeck.MonoRegular
|
||||
}
|
||||
|
||||
ks := d.cfg.KeySize
|
||||
charW := fontSize * 0.55
|
||||
textW := int(float64(len(text)) * charW)
|
||||
posX := (ks - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
posY := ks/2 - 24 + int(fontSize/3)
|
||||
if posY < 2 {
|
||||
posY = 2
|
||||
}
|
||||
|
||||
return d.sd.WriteText(keyIndex, streamdeck.TextButton{
|
||||
Lines: []streamdeck.TextLine{
|
||||
{
|
||||
Text: text,
|
||||
PosX: posX,
|
||||
PosY: posY,
|
||||
Font: font,
|
||||
FontSize: fontSize,
|
||||
FontColor: color.White,
|
||||
},
|
||||
},
|
||||
BgColor: bg,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Deck) WriteTextOnImage(keyIndex int, img image.Image, text, fontName string, fontSize float64, showLabelBackground bool) error {
|
||||
ks := d.cfg.KeySize
|
||||
|
||||
g := gift.New(
|
||||
gift.Resize(ks, ks, gift.LanczosResampling),
|
||||
)
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, ks, ks))
|
||||
g.Draw(rgba, img)
|
||||
|
||||
lines := []streamdeck.TextLine{}
|
||||
if text != "" {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 16
|
||||
}
|
||||
font := streamdeck.MonoRegular
|
||||
if fontName == "medium" {
|
||||
font = streamdeck.MonoMedium
|
||||
}
|
||||
|
||||
if showLabelBackground {
|
||||
barHeight := 20
|
||||
if ks < 72 {
|
||||
barHeight = 18
|
||||
}
|
||||
barRect := image.Rect(0, ks-barHeight, ks, ks)
|
||||
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||
}
|
||||
|
||||
offsetY := 24
|
||||
baselineY := ks - 6
|
||||
posY := baselineY - offsetY
|
||||
if posY < 2 {
|
||||
posY = 2
|
||||
}
|
||||
|
||||
charW := fontSize * 0.55
|
||||
textW := int(float64(utf8.RuneCountInString(text)) * charW)
|
||||
posX := (ks - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
|
||||
lines = append(lines, streamdeck.TextLine{
|
||||
Text: text,
|
||||
PosX: posX,
|
||||
PosY: posY,
|
||||
Font: font,
|
||||
FontSize: fontSize,
|
||||
FontColor: color.White,
|
||||
})
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.closed {
|
||||
return fmt.Errorf("deck closed")
|
||||
}
|
||||
return d.sd.WriteTextOnImage(keyIndex, rgba, lines)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,212 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
_ "image/jpeg"
|
||||
|
||||
"github.com/disintegration/gift"
|
||||
)
|
||||
|
||||
type cbdtIndex struct {
|
||||
once sync.Once
|
||||
imgs []image.Image
|
||||
scale int
|
||||
}
|
||||
|
||||
var emojiCBDT cbdtIndex
|
||||
|
||||
func loadCBDT(data []byte) ([]byte, []byte, error) {
|
||||
if len(data) < 12 {
|
||||
return nil, nil, fmt.Errorf("font too small")
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
off := 12
|
||||
var cmapData, cbdtData []byte
|
||||
for i := 0; i < numTables; i++ {
|
||||
if off+16 > len(data) {
|
||||
break
|
||||
}
|
||||
tag := string(data[off : off+4])
|
||||
tblOff := int(binary.BigEndian.Uint32(data[off+8 : off+12]))
|
||||
tblLen := int(binary.BigEndian.Uint32(data[off+12 : off+16]))
|
||||
switch tag {
|
||||
case "cmap":
|
||||
if tblOff+tblLen <= len(data) {
|
||||
cmapData = data[tblOff : tblOff+tblLen]
|
||||
}
|
||||
case "CBDT":
|
||||
if tblOff+tblLen <= len(data) {
|
||||
cbdtData = data[tblOff : tblOff+tblLen]
|
||||
}
|
||||
}
|
||||
off += 16
|
||||
}
|
||||
if cmapData == nil {
|
||||
return nil, nil, fmt.Errorf("cmap table not found")
|
||||
}
|
||||
if cbdtData == nil {
|
||||
return nil, nil, fmt.Errorf("CBDT table not found")
|
||||
}
|
||||
return cmapData, cbdtData, nil
|
||||
}
|
||||
|
||||
func cmapGlyphIndex(cmap []byte, r rune) (int, bool) {
|
||||
if len(cmap) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
|
||||
for i := 0; i < numTables; i++ {
|
||||
boff := 4 + i*8
|
||||
if boff+8 > len(cmap) {
|
||||
break
|
||||
}
|
||||
platform := binary.BigEndian.Uint16(cmap[boff : boff+2])
|
||||
encoding := binary.BigEndian.Uint16(cmap[boff+2 : boff+4])
|
||||
if platform != 3 || encoding != 10 {
|
||||
continue
|
||||
}
|
||||
subOff := int(binary.BigEndian.Uint32(cmap[boff+4 : boff+8]))
|
||||
if subOff+2 > len(cmap) {
|
||||
continue
|
||||
}
|
||||
fmtRaw := binary.BigEndian.Uint16(cmap[subOff:])
|
||||
if fmtRaw != 12 {
|
||||
continue
|
||||
}
|
||||
return cmapFormat12(cmap[subOff:], r)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cmapFormat12(data []byte, r rune) (int, bool) {
|
||||
if len(data) < 16 {
|
||||
return 0, false
|
||||
}
|
||||
numGroups := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
cp := uint32(r)
|
||||
lo, hi := 0, numGroups-1
|
||||
for lo <= hi {
|
||||
mid := (lo + hi) / 2
|
||||
goff := 16 + mid*12
|
||||
if goff+12 > len(data) {
|
||||
break
|
||||
}
|
||||
start := binary.BigEndian.Uint32(data[goff:])
|
||||
end := binary.BigEndian.Uint32(data[goff+4:])
|
||||
startGlyph := binary.BigEndian.Uint32(data[goff+8:])
|
||||
if cp < start {
|
||||
hi = mid - 1
|
||||
} else if cp > end {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
return int(startGlyph + (cp - start)), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func scanCBDTImages(cbdt []byte, firstGlyph int) ([]image.Image, error) {
|
||||
if len(cbdt) < 4 {
|
||||
return nil, fmt.Errorf("CBDT too small")
|
||||
}
|
||||
pos := 4
|
||||
var imgs []image.Image
|
||||
for pos < len(cbdt) {
|
||||
if pos+9 > len(cbdt) {
|
||||
break
|
||||
}
|
||||
dataSize := int(binary.BigEndian.Uint16(cbdt[pos+7 : pos+9]))
|
||||
pngOff := pos + 9
|
||||
if pngOff+dataSize > len(cbdt) {
|
||||
break
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(cbdt[pngOff : pngOff+dataSize]))
|
||||
if err != nil {
|
||||
pos += 9 + dataSize
|
||||
continue
|
||||
}
|
||||
imgs = append(imgs, img)
|
||||
pos += 9 + dataSize
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
return nil, fmt.Errorf("no valid PNG images found in CBDT")
|
||||
}
|
||||
slog.Debug("CBDT scan", "images", len(imgs), "firstGlyph", firstGlyph)
|
||||
return imgs, nil
|
||||
}
|
||||
|
||||
func renderCBDTGlyph(r rune, targetSize int, scale float64) (image.Image, bool) {
|
||||
fontData := loadColorEmojiFont()
|
||||
if fontData == nil {
|
||||
return nil, false
|
||||
}
|
||||
cmap, cbdt, err := loadCBDT(fontData)
|
||||
if err != nil {
|
||||
slog.Debug("CBDT load", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
gid, ok := cmapGlyphIndex(cmap, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
emojiCBDT.once.Do(func() {
|
||||
imgs, err := scanCBDTImages(cbdt, 4)
|
||||
if err != nil {
|
||||
slog.Warn("CBDT scan", "error", err)
|
||||
return
|
||||
}
|
||||
emojiCBDT.imgs = imgs
|
||||
})
|
||||
if emojiCBDT.imgs == nil {
|
||||
return nil, false
|
||||
}
|
||||
idx := gid - 5
|
||||
if idx < 0 || idx >= len(emojiCBDT.imgs) {
|
||||
return nil, false
|
||||
}
|
||||
raw := emojiCBDT.imgs[idx]
|
||||
|
||||
displaySize := int(float64(targetSize) * scale)
|
||||
if displaySize > targetSize {
|
||||
displaySize = targetSize
|
||||
}
|
||||
if displaySize < 1 {
|
||||
displaySize = 1
|
||||
}
|
||||
|
||||
scaled := raw
|
||||
if raw.Bounds().Dx() != displaySize || raw.Bounds().Dy() != displaySize {
|
||||
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
||||
g.Draw(rgba, raw)
|
||||
scaled = rgba
|
||||
}
|
||||
|
||||
out := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
|
||||
offX := (targetSize - scaled.Bounds().Dx()) / 2
|
||||
offY := (targetSize - scaled.Bounds().Dy()) / 2
|
||||
rect := image.Rect(offX, offY, offX+scaled.Bounds().Dx(), offY+scaled.Bounds().Dy())
|
||||
draw.Draw(out, rect, scaled, image.Point{}, draw.Over)
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
func scaleToTarget(img image.Image, targetSize int) image.Image {
|
||||
b := img.Bounds()
|
||||
if b.Dx() == targetSize && b.Dy() == targetSize {
|
||||
return img
|
||||
}
|
||||
g := gift.New(gift.Resize(targetSize, targetSize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
g.Draw(rgba, img)
|
||||
return rgba
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
func parseFAIcon(ref string) (faStyle, string, error) {
|
||||
if !strings.HasPrefix(ref, "fa") {
|
||||
return 0, "", fmt.Errorf("not a font awesome ref")
|
||||
}
|
||||
|
||||
var style faStyle
|
||||
var name string
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(ref, "fab:"):
|
||||
style = faBrands
|
||||
name = strings.TrimPrefix(ref, "fab:")
|
||||
case strings.HasPrefix(ref, "far:"):
|
||||
style = faRegular
|
||||
name = strings.TrimPrefix(ref, "far:")
|
||||
case strings.HasPrefix(ref, "fa:"):
|
||||
style = faSolid
|
||||
name = strings.TrimPrefix(ref, "fa:")
|
||||
default:
|
||||
return 0, "", fmt.Errorf("invalid font awesome ref: %s", ref)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return 0, "", fmt.Errorf("empty icon name")
|
||||
}
|
||||
|
||||
return style, name, nil
|
||||
}
|
||||
|
||||
func faCodepoint(style faStyle, name string) (rune, error) {
|
||||
m, ok := faCodepoints[style]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown style")
|
||||
}
|
||||
|
||||
if cp, ok := m[name]; ok {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
if cp, ok := m[normalizeFAName(name)]; ok {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("icon %q not found", name)
|
||||
}
|
||||
|
||||
func normalizeFAName(name string) string {
|
||||
if len(name) == 0 {
|
||||
return name
|
||||
}
|
||||
parts := strings.Split(name, "-")
|
||||
for i, p := range parts {
|
||||
if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
}
|
||||
camel := strings.Join(parts, "")
|
||||
return strings.ToLower(camel[:1]) + camel[1:]
|
||||
}
|
||||
|
||||
func faFontBytes(style faStyle) ([]byte, error) {
|
||||
return faFonts.ReadFile(style.otfPath())
|
||||
}
|
||||
|
||||
func loadFAFace(style faStyle, pointSize float64) (font.Face, error) {
|
||||
data, err := faFontBytes(style)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read font: %w", err)
|
||||
}
|
||||
|
||||
fnt, err := opentype.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse font: %w", err)
|
||||
}
|
||||
|
||||
face, err := opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||
Size: pointSize,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new face: %w", err)
|
||||
}
|
||||
return face, nil
|
||||
}
|
||||
|
||||
func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.Image, error) {
|
||||
cp, err := faCodepoint(style, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scale <= 0 {
|
||||
scale = 0.55
|
||||
}
|
||||
fontSize := float64(size) * scale
|
||||
face, err := loadFAFace(style, fontSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer face.Close()
|
||||
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
adv := font.MeasureString(face, string(cp)).Ceil()
|
||||
offX := (size - adv) / 2
|
||||
if offX < 0 {
|
||||
offX = 0
|
||||
}
|
||||
|
||||
metrics := face.Metrics()
|
||||
baselineY := (size + metrics.Ascent.Ceil() - metrics.Descent.Ceil()) / 2
|
||||
|
||||
d := font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(offX, baselineY),
|
||||
}
|
||||
d.DrawString(string(cp))
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
func isFAIconRef(path string) bool {
|
||||
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
//go:embed assets/Font_Awesome_7_BrandsRegular400.otf
|
||||
//go:embed assets/Font_Awesome_7_FreeRegular400.otf
|
||||
//go:embed assets/Font_Awesome_7_FreeSolid900.otf
|
||||
var faFonts embed.FS
|
||||
|
||||
type faStyle int
|
||||
|
||||
const (
|
||||
faSolid faStyle = iota
|
||||
faRegular
|
||||
faBrands
|
||||
)
|
||||
|
||||
func (s faStyle) otfPath() string {
|
||||
switch s {
|
||||
case faSolid:
|
||||
return "assets/Font_Awesome_7_FreeSolid900.otf"
|
||||
case faRegular:
|
||||
return "assets/Font_Awesome_7_FreeRegular400.otf"
|
||||
case faBrands:
|
||||
return "assets/Font_Awesome_7_BrandsRegular400.otf"
|
||||
default:
|
||||
return "assets/Font_Awesome_7_FreeSolid900.otf"
|
||||
}
|
||||
}
|
||||
|
||||
var faCodepoints map[faStyle]map[string]rune
|
||||
|
||||
func init() {
|
||||
faCodepoints = make(map[faStyle]map[string]rune)
|
||||
faCodepoints[faSolid] = buildFAMap(fa7Icons)
|
||||
faCodepoints[faRegular] = buildFAMap(fa7Icons)
|
||||
faCodepoints[faBrands] = buildFAMap(fa7BrandsIcons)
|
||||
}
|
||||
|
||||
func buildFAMap(src map[string]string) map[string]rune {
|
||||
m := make(map[string]rune, len(src))
|
||||
for name, cp := range src {
|
||||
r, _ := utf8.DecodeRuneInString(cp)
|
||||
camel := camelToKebab(name)
|
||||
m[name] = r
|
||||
m[camel] = r
|
||||
if lower := toLower(name); lower != name {
|
||||
m[lower] = r
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func camelToKebab(s string) string {
|
||||
var out []byte
|
||||
for i, r := range s {
|
||||
if unicode.IsUpper(r) && i > 0 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, byte(unicode.ToLower(r)))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
return string(unicode.ToLower(rune(s[0]))) + s[1:]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func iconThemeDirs() []string {
|
||||
dirs := []string{}
|
||||
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
dirs = append(dirs, filepath.Join(home, ".local/share/icons"))
|
||||
}
|
||||
|
||||
xdgDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if xdgDirs == "" {
|
||||
xdgDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, d := range filepath.SplitList(xdgDirs) {
|
||||
dirs = append(dirs, filepath.Join(d, "icons"))
|
||||
}
|
||||
|
||||
return uniquePaths(dirs)
|
||||
}
|
||||
|
||||
func uniquePaths(paths []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
res := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
res = append(res, p)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func preferredThemes() []string {
|
||||
theme := detectGtkTheme()
|
||||
if theme != "" {
|
||||
return []string{theme, "hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
||||
}
|
||||
return []string{"hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
||||
}
|
||||
|
||||
func detectGtkTheme() string {
|
||||
data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-4.0/settings.ini"))
|
||||
if err != nil {
|
||||
data, err = os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-3.0/settings.ini"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "gtk-icon-theme-name=") {
|
||||
return strings.TrimSpace(line[len("gtk-icon-theme-name="):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sizeDirs(target int) []string {
|
||||
sizes := []int{}
|
||||
|
||||
for _, base := range []int{16, 22, 24, 32, 48, 64, 72, 96, 128, 192, 256} {
|
||||
sizes = append(sizes, base)
|
||||
}
|
||||
|
||||
sort.Slice(sizes, func(i, j int) bool {
|
||||
di := abs(sizes[i] - target)
|
||||
dj := abs(sizes[j] - target)
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return sizes[i] > sizes[j]
|
||||
})
|
||||
|
||||
seen := make(map[int]bool)
|
||||
res := make([]string, 0, len(sizes))
|
||||
for _, s := range sizes {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
res = append(res, fmt.Sprintf("%dx%d", s, s))
|
||||
}
|
||||
res = append(res, "scalable")
|
||||
return res
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func iconCategories() []string {
|
||||
return []string{"actions", "apps", "categories", "devices", "emblems", "mimetypes", "places", "status"}
|
||||
}
|
||||
|
||||
func findSystemIcon(name string, targetSize int) (string, error) {
|
||||
themes := preferredThemes()
|
||||
dirs := iconThemeDirs()
|
||||
sDirs := sizeDirs(targetSize)
|
||||
cats := iconCategories()
|
||||
|
||||
for _, base := range dirs {
|
||||
for _, theme := range themes {
|
||||
themeDir := filepath.Join(base, theme)
|
||||
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sd := range sDirs {
|
||||
for _, cat := range cats {
|
||||
for _, ext := range []string{"png", "xpm"} {
|
||||
p := filepath.Join(themeDir, sd, cat, name+"."+ext)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sd := range sDirs {
|
||||
for _, cat := range cats {
|
||||
p := filepath.Join(themeDir, sd, cat, name+".svg")
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("system icon %q not found", name)
|
||||
}
|
||||
|
||||
func svgToPNG(svgPath string, targetSize int, scale float64) (image.Image, error) {
|
||||
if scale <= 0 {
|
||||
scale = 0.55
|
||||
}
|
||||
|
||||
renderSize := int(float64(targetSize) * scale)
|
||||
if renderSize > targetSize {
|
||||
renderSize = targetSize
|
||||
}
|
||||
if renderSize < 1 {
|
||||
renderSize = 1
|
||||
}
|
||||
|
||||
cmd := exec.Command("rsvg-convert",
|
||||
"-w", strconv.Itoa(renderSize),
|
||||
"-h", strconv.Itoa(renderSize),
|
||||
"-f", "png",
|
||||
svgPath,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rsvg-convert: %w", err)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode converted svg: %w", err)
|
||||
}
|
||||
|
||||
if renderSize < targetSize {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||
offX := (targetSize - renderSize) / 2
|
||||
offY := (targetSize - renderSize) / 2
|
||||
draw.Draw(canvas, image.Rect(offX, offY, offX+renderSize, offY+renderSize), img, image.Point{}, draw.Over)
|
||||
img = canvas
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
var iconSizeCache sync.Map
|
||||
|
||||
func loadSystemIcon(name string, targetSize int) (string, error) {
|
||||
type cacheKey struct {
|
||||
name string
|
||||
size int
|
||||
}
|
||||
key := cacheKey{name, targetSize}
|
||||
if cached, ok := iconSizeCache.Load(key); ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
path, err := findSystemIcon(name, targetSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iconSizeCache.Store(key, path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isSystemIconRef(path string) bool {
|
||||
return strings.HasPrefix(path, "@")
|
||||
}
|
||||
|
||||
func systemIconName(path string) string {
|
||||
return strings.TrimPrefix(path, "@")
|
||||
}
|
||||
|
||||
func parseSizeDir(dirName string) (int, bool) {
|
||||
parts := strings.SplitN(dirName, "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
s, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
|
||||
"github.com/disintegration/gift"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool) image.Image {
|
||||
if k == nil {
|
||||
return blankImage(keySize, color.RGBA{0, 0, 0, 255})
|
||||
}
|
||||
if k.Icon == "" && k.Label == "" {
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
return blankImage(keySize, bg)
|
||||
}
|
||||
}
|
||||
return blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
||||
}
|
||||
|
||||
faScale := 0.55
|
||||
if k.IconScale != nil {
|
||||
faScale = *k.IconScale
|
||||
}
|
||||
|
||||
if k.Icon != "" {
|
||||
img, err := LoadImage(k.Icon, keySize, faScale)
|
||||
if err != nil {
|
||||
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
||||
}
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
img = applyBackground(img, bg)
|
||||
}
|
||||
}
|
||||
if k.Label != "" {
|
||||
fontSize := 10.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return composeImageWithLabel(img, k.Label, keySize, fontSize, showLabelBackground)
|
||||
}
|
||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||
g.Draw(rgba, img)
|
||||
return rgba
|
||||
}
|
||||
|
||||
if k.Background != "" {
|
||||
if bg, err := parseHexColor(k.Background); err == nil {
|
||||
if k.Label != "" {
|
||||
fontSize := 12.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return renderUnicodeText(k.Label, fontSize, keySize, bg, color.White)
|
||||
}
|
||||
return blankImage(keySize, bg)
|
||||
}
|
||||
}
|
||||
fontSize := 12.0
|
||||
if k.FontSize != nil {
|
||||
fontSize = *k.FontSize
|
||||
}
|
||||
return renderTextImage(k.Label, keySize, fontSize)
|
||||
}
|
||||
|
||||
func blankImage(size int, c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
return img
|
||||
}
|
||||
|
||||
func composeImageWithLabel(src image.Image, text string, keySize int, fontSize float64, showLabelBackground bool) image.Image {
|
||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||
g.Draw(rgba, src)
|
||||
|
||||
barHeight := 20
|
||||
if keySize < 72 {
|
||||
barHeight = 18
|
||||
}
|
||||
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
|
||||
if showLabelBackground {
|
||||
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
face, err := parseDisplayFace(fontSize)
|
||||
if err != nil {
|
||||
face = basicfont.Face7x13
|
||||
} else {
|
||||
defer face.Close()
|
||||
}
|
||||
textW := font.MeasureString(face, text).Ceil()
|
||||
posX := (keySize - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
posY := keySize - barHeight/2 + face.Metrics().Height.Ceil()/2
|
||||
if posY >= keySize {
|
||||
posY = keySize - 2
|
||||
}
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(posX, posY),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
return rgba
|
||||
}
|
||||
|
||||
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA)
|
||||
|
||||
if text != "" {
|
||||
face, err := parseDisplayFace(fontSize)
|
||||
if err != nil {
|
||||
face = basicfont.Face7x13
|
||||
} else {
|
||||
defer face.Close()
|
||||
}
|
||||
textW := font.MeasureString(face, text).Ceil()
|
||||
posX := (keySize - textW) / 2
|
||||
if posX < 2 {
|
||||
posX = 2
|
||||
}
|
||||
posY := keySize/2 + face.Metrics().Height.Ceil()/2
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: rgba,
|
||||
Src: image.NewUniform(color.White),
|
||||
Face: face,
|
||||
Dot: fixed.P(posX, posY),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
return rgba
|
||||
}
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,745 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('deckApp', () => ({
|
||||
// ── State ──
|
||||
config: null,
|
||||
pages: [],
|
||||
activePage: '',
|
||||
view: 'grid',
|
||||
showAdvanced: false,
|
||||
toast: null,
|
||||
displayOutputs: {},
|
||||
isCapturingKey: false,
|
||||
decks: [],
|
||||
activeDeckSerial: '',
|
||||
|
||||
// ── Edit state ──
|
||||
editing: null,
|
||||
editForm: {
|
||||
bg_color: '',
|
||||
actions: {
|
||||
tap: {},
|
||||
long_press: {},
|
||||
double_tap: {},
|
||||
hold: { start: {}, end: {} },
|
||||
},
|
||||
},
|
||||
|
||||
// ── Sub-views ──
|
||||
subView: null,
|
||||
|
||||
// ── Settings ──
|
||||
settingsForm: {},
|
||||
autoSwitchRules: [],
|
||||
|
||||
// ── Constants ──
|
||||
actionIconMap: {
|
||||
command: 'terminal',
|
||||
builtin: 'cog',
|
||||
script: 'file-code',
|
||||
page: 'layer-group',
|
||||
keyboard: 'keyboard',
|
||||
},
|
||||
actionTitleMap: {
|
||||
command: 'Command',
|
||||
builtin: 'Built-in',
|
||||
script: 'Script',
|
||||
page: 'Switch page',
|
||||
keyboard: 'Keyboard shortcut',
|
||||
},
|
||||
|
||||
// ── Init ──
|
||||
async init() {
|
||||
await this.loadConfig()
|
||||
await this.loadDecks()
|
||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||
setInterval(() => this.loadEffectiveKeys(), 10000)
|
||||
this.connectSSE()
|
||||
},
|
||||
|
||||
connectSSE() {
|
||||
if (this._sse) this._sse.close()
|
||||
const es = new EventSource('/api/events')
|
||||
this._sse = es
|
||||
es.addEventListener('page_changed', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.page && this.pages.find(p => p.name === data.page)) {
|
||||
this.activePage = data.page
|
||||
localStorage.setItem('sd_active_page', data.page)
|
||||
this.displayOutputs = {}
|
||||
}
|
||||
} catch (_) {}
|
||||
})
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
this._sse = null
|
||||
setTimeout(() => this.connectSSE(), 3000)
|
||||
}
|
||||
},
|
||||
|
||||
async loadDecks() {
|
||||
try {
|
||||
const res = await fetch('/api/decks')
|
||||
if (res.ok) {
|
||||
this.decks = await res.json()
|
||||
if (this.decks.length > 0 && !this.activeDeckSerial) {
|
||||
this.activeDeckSerial = this.decks[0].serial
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async pollDisplayOutputs() {
|
||||
if (this.view !== 'grid') return
|
||||
try {
|
||||
const res = await fetch(`/api/display-outputs`)
|
||||
this.displayOutputs = await res.json()
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
this.config = await res.json()
|
||||
this.pages = this.config.pages
|
||||
await this.loadEffectiveKeys()
|
||||
if (this.pages.length > 0) {
|
||||
const saved = localStorage.getItem('sd_active_page')
|
||||
if (saved && this.pages.find(p => p.name === saved)) {
|
||||
this.activePage = saved
|
||||
} else {
|
||||
this.activePage = this.config.default_page || this.pages[0].name
|
||||
}
|
||||
}
|
||||
this.syncSettings()
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load config', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
async loadEffectiveKeys() {
|
||||
try {
|
||||
const res = await fetch('/api/pages')
|
||||
const pagesData = await res.json()
|
||||
for (const pd of pagesData) {
|
||||
const page = this.pages.find(p => p.name === pd.name)
|
||||
if (page && pd.effective_keys && pd.effective_keys.length > 0) {
|
||||
page.effective_keys = pd.effective_keys
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
syncSettings() {
|
||||
this.settingsForm = {
|
||||
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
||||
font: this.config.font || 'medium',
|
||||
show_label_background: this.config.show_label_background ?? true,
|
||||
screensaver_enabled: this.config.screensaver?.enabled || false,
|
||||
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
|
||||
screensaver_brightness: this.config.screensaver?.brightness || 10,
|
||||
long_press_ms: this.config.timing?.long_press_ms || 500,
|
||||
double_tap_ms: this.config.timing?.double_tap_ms || 300,
|
||||
}
|
||||
this.autoSwitchRules = [...(this.config.auto_switch || [])]
|
||||
},
|
||||
|
||||
// ── Page helpers ──
|
||||
get currentPage() {
|
||||
return this.pages.find(p => p.name === this.activePage)
|
||||
},
|
||||
|
||||
get pageNames() {
|
||||
return this.pages.map(p => p.name)
|
||||
},
|
||||
|
||||
get activeDeck() {
|
||||
return this.decks.find(d => d.serial === this.activeDeckSerial)
|
||||
|| { keys_x: 5, keys_y: 3, num_keys: 15 }
|
||||
},
|
||||
|
||||
getDeviceConfig(serial) {
|
||||
return this.config.devices?.find(d => d.serial === serial)
|
||||
},
|
||||
|
||||
// ── Grid ──
|
||||
get gridKeys() {
|
||||
const page = this.currentPage
|
||||
if (!page) return []
|
||||
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
|
||||
? page.effective_keys
|
||||
: (page.keys || [])
|
||||
const keys = []
|
||||
const n = this.activeDeck.num_keys
|
||||
for (let i = 0; i < n; i++) {
|
||||
const kc = sourceKeys.find(k => k.index === i)
|
||||
const dout = this.displayOutputs[i]
|
||||
keys.push({
|
||||
index: i,
|
||||
...kc,
|
||||
configured: !!kc,
|
||||
hasDisplay: !!(kc?.display),
|
||||
hasAction: !!(kc?.actions?.length),
|
||||
actionTypes: this.getActionTypes(kc?.actions || []),
|
||||
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)),
|
||||
})
|
||||
}
|
||||
return keys
|
||||
},
|
||||
|
||||
getActionTypes(actions) {
|
||||
const seen = {}
|
||||
for (const a of actions) {
|
||||
if (a.type && a.type !== 'none') {
|
||||
seen[a.type] = true
|
||||
}
|
||||
}
|
||||
return Object.keys(seen)
|
||||
},
|
||||
|
||||
keyPreviewUrl(kc, dout) {
|
||||
const k = kc || {}
|
||||
let url = '/api/render?key_size=72'
|
||||
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||
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}`
|
||||
const bg = dout?.background || k.background
|
||||
if (bg) url += `&background=${encodeURIComponent(bg)}`
|
||||
return url
|
||||
},
|
||||
|
||||
isFAIcon(icon) {
|
||||
if (!icon) return false
|
||||
return icon.startsWith('fa:') || icon.startsWith('far:') || icon.startsWith('fab:')
|
||||
},
|
||||
|
||||
faClass(icon) {
|
||||
if (!icon) return ''
|
||||
if (icon.startsWith('fa:')) return 'fa-solid fa-' + icon.slice(3)
|
||||
if (icon.startsWith('far:')) return 'fa-regular fa-' + icon.slice(4)
|
||||
if (icon.startsWith('fab:')) return 'fa-brands fa-' + icon.slice(4)
|
||||
return ''
|
||||
},
|
||||
|
||||
// ── Multi-action helpers ──
|
||||
getCurrentAction() {
|
||||
const t = this.editForm.activeTrigger || 'tap'
|
||||
if (t === 'hold') {
|
||||
return this.editForm.actions?.hold?.[this.editForm.holdPhase || 'start']
|
||||
}
|
||||
return this.editForm.actions?.[t]
|
||||
},
|
||||
|
||||
hasCurrentAction(trigger) {
|
||||
const a = this.editForm.actions?.[trigger]
|
||||
return a && a.type && a.type !== 'none'
|
||||
},
|
||||
|
||||
hasHoldAction(phase) {
|
||||
const a = this.editForm.actions?.hold?.[phase]
|
||||
return a && a.type && a.type !== 'none'
|
||||
},
|
||||
|
||||
triggerLabel(trigger) {
|
||||
return { tap: 'Tap', long_press: 'Long Press', double_tap: 'Double Tap', hold: 'Hold' }[trigger] || trigger
|
||||
},
|
||||
|
||||
// ── Keyboard helpers (for current action) ──
|
||||
get keyChips() {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a || !a.keys) return []
|
||||
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
|
||||
return a.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
|
||||
},
|
||||
|
||||
toggleMod(mod) {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a) return
|
||||
if (mod === 'ctrl') a.modCtrl = !a.modCtrl
|
||||
else if (mod === 'alt') a.modAlt = !a.modAlt
|
||||
else if (mod === 'shift') a.modShift = !a.modShift
|
||||
else if (mod === 'super') a.modSuper = !a.modSuper
|
||||
this.rebuildKeys()
|
||||
},
|
||||
|
||||
rebuildKeys() {
|
||||
const a = this.getCurrentAction()
|
||||
if (!a) return
|
||||
const mods = []
|
||||
if (a.modCtrl) mods.push('ctrl')
|
||||
if (a.modAlt) mods.push('alt')
|
||||
if (a.modShift) mods.push('shift')
|
||||
if (a.modSuper) mods.push('super')
|
||||
if (a.mainKey) mods.push(a.mainKey)
|
||||
a.keys = mods.join('+')
|
||||
},
|
||||
|
||||
startKeyCapture() {
|
||||
this.isCapturingKey = true
|
||||
this.$nextTick(() => {
|
||||
this.$el.querySelectorAll('.key-capture').forEach(el => {
|
||||
if (el.offsetParent !== null) el.focus()
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onCaptureKey(e) {
|
||||
if (!this.isCapturingKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const map = {
|
||||
'Control': '', 'Alt': '', 'Shift': '', 'Meta': '',
|
||||
' ': 'space', 'Enter': 'return', 'Tab': 'tab',
|
||||
'Escape': 'escape', 'Delete': 'delete', 'Backspace': 'backspace',
|
||||
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
|
||||
}
|
||||
let key = map[e.key]
|
||||
if (key === undefined) {
|
||||
key = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
}
|
||||
if (!key) return
|
||||
const a = this.getCurrentAction()
|
||||
if (a) a.mainKey = key.toLowerCase()
|
||||
this.isCapturingKey = false
|
||||
this.rebuildKeys()
|
||||
},
|
||||
|
||||
displayKey(key) {
|
||||
const map = { return: '\u21B5', escape: 'Esc', tab: 'Tab', space: '\u2423',
|
||||
delete: 'Del', backspace: '\u232B', up: '\u2191', down: '\u2193', left: '\u2190', right: '\u2192' }
|
||||
return map[key] || key.toUpperCase()
|
||||
},
|
||||
|
||||
// ── Edit Key ──
|
||||
defaultAction(trigger) {
|
||||
return { trigger, type: 'none', command: '', builtin: '', script: '', page: '', keys: '', background: true, modCtrl: false, modAlt: false, modShift: false, modSuper: false, mainKey: '' }
|
||||
},
|
||||
|
||||
editKey(idx) {
|
||||
this.isCapturingKey = false
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
const kc = page.keys.find(k => k.index === idx)
|
||||
if (!kc && page.dynamic_keys && page.effective_keys?.find(k => k.index === idx)) {
|
||||
return
|
||||
}
|
||||
this.editing = idx
|
||||
this.showAdvanced = false
|
||||
|
||||
const existing = kc?.actions || []
|
||||
|
||||
const tap = existing.find(a => a.trigger === 'tap') || null
|
||||
const longPress = existing.find(a => a.trigger === 'long_press') || null
|
||||
const doubleTap = existing.find(a => a.trigger === 'double_tap') || null
|
||||
const holdStart = existing.find(a => a.trigger === 'hold_start') || null
|
||||
const holdEnd = existing.find(a => a.trigger === 'hold_end') || null
|
||||
|
||||
const build = (src, trigger) => {
|
||||
const def = this.defaultAction(trigger)
|
||||
if (!src) return def
|
||||
const parts = (src.keys || '').toLowerCase().split('+')
|
||||
const main = parts.length > 0 ? parts.pop() : ''
|
||||
return {
|
||||
...def,
|
||||
type: src.type || 'none',
|
||||
command: src.command || '',
|
||||
builtin: src.builtin || '',
|
||||
script: src.script || '',
|
||||
page: src.page || '',
|
||||
keys: src.keys || '',
|
||||
background: src.background ?? true,
|
||||
modCtrl: parts.includes('ctrl'),
|
||||
modAlt: parts.includes('alt'),
|
||||
modShift: parts.includes('shift'),
|
||||
modSuper: parts.includes('super'),
|
||||
mainKey: main,
|
||||
}
|
||||
}
|
||||
|
||||
this.editForm = {
|
||||
icon: kc?.icon || '',
|
||||
label: kc?.label || '',
|
||||
icon_scale: kc?.icon_scale ?? 0.55,
|
||||
font_size: kc?.font_size ?? 16,
|
||||
bg_color: kc?.background || '',
|
||||
activeTrigger: 'tap',
|
||||
holdPhase: 'start',
|
||||
actions: {
|
||||
tap: build(tap, 'tap'),
|
||||
long_press: build(longPress, 'long_press'),
|
||||
double_tap: build(doubleTap, 'double_tap'),
|
||||
hold: {
|
||||
start: build(holdStart, 'hold_start'),
|
||||
end: build(holdEnd, 'hold_end'),
|
||||
},
|
||||
},
|
||||
display_mode: kc?.display ? (kc.display.command ? 'command' : 'script') : 'none',
|
||||
display_command: kc?.display?.command || '',
|
||||
display_script: kc?.display?.script || '',
|
||||
display_interval: kc?.display?.interval || '30s',
|
||||
display_max_len: kc?.display?.max_len || 128,
|
||||
display_timeout: kc?.display?.timeout || '',
|
||||
}
|
||||
},
|
||||
|
||||
get editPreviewUrl() {
|
||||
const f = this.editForm
|
||||
let url = '/api/render?key_size=96'
|
||||
if (f.icon) url += `&icon=${encodeURIComponent(f.icon)}`
|
||||
if (f.label) url += `&label=${encodeURIComponent(f.label)}`
|
||||
if (f.icon_scale != null) url += `&icon_scale=${f.icon_scale}`
|
||||
if (f.font_size != null) url += `&font_size=${f.font_size}`
|
||||
if (f.bg_color) url += `&background=${encodeURIComponent(f.bg_color)}`
|
||||
return url
|
||||
},
|
||||
|
||||
get editPreviewFaClass() {
|
||||
return this.faClass(this.editForm.icon)
|
||||
},
|
||||
|
||||
async saveKey() {
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
|
||||
const dm = this.editForm.display_mode
|
||||
const hasDisplay = dm !== 'none'
|
||||
|
||||
if (dm === 'command' && !this.editForm.display_command) {
|
||||
this.showToast('Display command is required', 'error')
|
||||
return
|
||||
}
|
||||
if (dm === 'script' && !this.editForm.display_script) {
|
||||
this.showToast('Display script path is required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate and collect actions
|
||||
const actions = []
|
||||
const checkAction = (a) => {
|
||||
if (!a || !a.type || a.type === 'none') return
|
||||
if (a.type === 'command' && !a.command) return
|
||||
if (a.type === 'script' && !a.script) return
|
||||
if (a.type === 'page' && !a.page) return
|
||||
if (a.type === 'keyboard' && !a.keys) return
|
||||
const entry = {
|
||||
trigger: a.trigger,
|
||||
type: a.type,
|
||||
command: a.type === 'command' ? a.command : undefined,
|
||||
builtin: a.type === 'builtin' ? a.builtin : undefined,
|
||||
script: a.type === 'script' ? a.script : undefined,
|
||||
page: a.type === 'page' ? a.page : undefined,
|
||||
keys: a.type === 'keyboard' ? a.keys : undefined,
|
||||
background: a.background || undefined,
|
||||
}
|
||||
// Clean undefined
|
||||
for (const k of Object.keys(entry)) {
|
||||
if (entry[k] === undefined) delete entry[k]
|
||||
}
|
||||
actions.push(entry)
|
||||
}
|
||||
checkAction(this.editForm.actions.tap)
|
||||
checkAction(this.editForm.actions.long_press)
|
||||
checkAction(this.editForm.actions.double_tap)
|
||||
checkAction(this.editForm.actions.hold.start)
|
||||
checkAction(this.editForm.actions.hold.end)
|
||||
|
||||
const kc = {
|
||||
index: this.editing,
|
||||
icon: this.editForm.icon || undefined,
|
||||
label: this.editForm.label || undefined,
|
||||
icon_scale: this.editForm.icon_scale !== 0.55 ? this.editForm.icon_scale : undefined,
|
||||
font_size: this.editForm.font_size !== 16 ? this.editForm.font_size : undefined,
|
||||
background: this.editForm.bg_color || undefined,
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
kc.actions = actions
|
||||
}
|
||||
|
||||
if (hasDisplay) {
|
||||
kc.display = {
|
||||
command: dm === 'command' ? this.editForm.display_command : '',
|
||||
script: dm === 'script' ? this.editForm.display_script : '',
|
||||
interval: this.editForm.display_interval || '30s',
|
||||
max_len: this.editForm.display_max_len || 0,
|
||||
}
|
||||
if (this.editForm.display_timeout) {
|
||||
kc.display.timeout = this.editForm.display_timeout
|
||||
}
|
||||
}
|
||||
|
||||
const existingIdx = page.keys.findIndex(k => k.index === this.editing)
|
||||
if (existingIdx >= 0) {
|
||||
page.keys[existingIdx] = kc
|
||||
} else {
|
||||
page.keys.push(kc)
|
||||
}
|
||||
|
||||
await this.saveConfig()
|
||||
this.editing = null
|
||||
},
|
||||
|
||||
async deleteKey() {
|
||||
const page = this.currentPage
|
||||
if (!page) return
|
||||
page.keys = page.keys.filter(k => k.index !== this.editing)
|
||||
await this.saveConfig()
|
||||
this.editing = null
|
||||
},
|
||||
|
||||
// ── Image picker ──
|
||||
pickImage() {
|
||||
this.$refs.fileInput.click()
|
||||
},
|
||||
|
||||
async onImagePicked(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) { this.showToast('Upload failed', 'error'); return }
|
||||
const data = await res.json()
|
||||
this.editForm.icon = data.path
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed', 'error')
|
||||
}
|
||||
e.target.value = ''
|
||||
},
|
||||
|
||||
// ── Page management ──
|
||||
switchPage(name) {
|
||||
this.activePage = name
|
||||
localStorage.setItem('sd_active_page', name)
|
||||
this.displayOutputs = {}
|
||||
fetch('/api/activate-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page: name })
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
async addPage() {
|
||||
const name = prompt('New page name:')
|
||||
if (!name || name.trim() === '') return
|
||||
if (this.pages.find(p => p.name === name)) {
|
||||
this.showToast('Page already exists', 'error')
|
||||
return
|
||||
}
|
||||
this.pages.push({ name: name.trim(), keys: [] })
|
||||
this.activePage = name.trim()
|
||||
this.config.pages = this.pages
|
||||
if (!this.config.default_page) {
|
||||
this.config.default_page = name.trim()
|
||||
}
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
async deletePage() {
|
||||
if (this.pages.length <= 1) {
|
||||
this.showToast('Cannot delete the last page', 'error')
|
||||
return
|
||||
}
|
||||
if (!confirm(`Delete page "${this.activePage}"?`)) return
|
||||
const oldName = this.activePage
|
||||
this.pages = this.pages.filter(p => p.name !== oldName)
|
||||
this.activePage = this.pages[0].name
|
||||
if (this.config.default_page === oldName) {
|
||||
this.config.default_page = this.activePage
|
||||
}
|
||||
this.config.pages = this.pages
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
async renamePage() {
|
||||
const oldName = this.activePage
|
||||
const newName = prompt('Rename page:', oldName)
|
||||
if (!newName || newName.trim() === '' || newName === oldName) return
|
||||
if (this.pages.find(p => p.name === newName.trim())) {
|
||||
this.showToast('Page already exists', 'error')
|
||||
return
|
||||
}
|
||||
const page = this.pages.find(p => p.name === oldName)
|
||||
if (page) page.name = newName.trim()
|
||||
this.activePage = newName.trim()
|
||||
if (this.config.default_page === oldName) {
|
||||
this.config.default_page = newName.trim()
|
||||
}
|
||||
|
||||
for (const p of this.pages) {
|
||||
for (const k of p.keys) {
|
||||
for (const a of (k.actions || [])) {
|
||||
if (a.page === oldName) a.page = newName.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const r of (this.config.auto_switch || [])) {
|
||||
if (r.page === oldName) r.page = newName.trim()
|
||||
}
|
||||
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
// ── Save / Reload ──
|
||||
async saveConfig() {
|
||||
this.config.pages = this.pages
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.config),
|
||||
})
|
||||
if (res.ok) {
|
||||
this.showToast('Saved', 'success')
|
||||
} else {
|
||||
const text = await res.text()
|
||||
this.showToast(`Save failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Save failed', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
async reloadConfig() {
|
||||
await this.loadConfig()
|
||||
this.showToast('Reloaded', 'success')
|
||||
},
|
||||
|
||||
// ── Settings ──
|
||||
async saveSettings() {
|
||||
if (!this.config.devices) this.config.devices = []
|
||||
let dev = this.config.devices.find(d => d.serial === this.activeDeckSerial)
|
||||
if (!dev) {
|
||||
dev = { serial: this.activeDeckSerial, brightness: 75 }
|
||||
this.config.devices.push(dev)
|
||||
}
|
||||
dev.brightness = parseInt(this.settingsForm.brightness)
|
||||
this.config.font = this.settingsForm.font || 'medium'
|
||||
this.config.show_label_background = this.settingsForm.show_label_background
|
||||
this.config.screensaver = {
|
||||
enabled: this.settingsForm.screensaver_enabled,
|
||||
idle_seconds: parseInt(this.settingsForm.screensaver_idle) || 30,
|
||||
brightness: parseInt(this.settingsForm.screensaver_brightness) || 10,
|
||||
}
|
||||
this.config.timing = {
|
||||
long_press_ms: parseInt(this.settingsForm.long_press_ms) || 500,
|
||||
double_tap_ms: parseInt(this.settingsForm.double_tap_ms) || 300,
|
||||
}
|
||||
this.config.auto_switch = this.autoSwitchRules
|
||||
await this.saveConfig()
|
||||
},
|
||||
|
||||
addAutoSwitchRule() {
|
||||
this.autoSwitchRules.push({ wm_class: '', title: '', page: '', stay: false, devices: [] })
|
||||
},
|
||||
|
||||
removeAutoSwitchRule(idx) {
|
||||
this.autoSwitchRules.splice(idx, 1)
|
||||
},
|
||||
|
||||
// ── Backup ──
|
||||
backups: [],
|
||||
|
||||
async loadBackups() {
|
||||
try {
|
||||
const res = await fetch('/api/backups')
|
||||
this.backups = await res.json()
|
||||
} catch (e) {
|
||||
this.backups = []
|
||||
}
|
||||
},
|
||||
|
||||
downloadConfig() {
|
||||
window.open('/api/config/download', '_blank')
|
||||
},
|
||||
|
||||
async downloadBackup(name) {
|
||||
window.open(`/api/backups/${encodeURIComponent(name)}`, '_blank')
|
||||
},
|
||||
|
||||
async restoreConfig() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/config/restore', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (res.ok) {
|
||||
await this.loadConfig()
|
||||
this.showToast('Config restored', 'success')
|
||||
} else {
|
||||
const text = await res.text()
|
||||
this.showToast(`Restore failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Restore failed', 'error')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
|
||||
async restoreBackup(filename) {
|
||||
if (!confirm(`Restore "${filename}"?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/backups/${encodeURIComponent(filename)}`)
|
||||
if (!res.ok) {
|
||||
this.showToast('Failed to download backup', 'error')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, filename)
|
||||
const putRes = await fetch('/api/config/restore', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (putRes.ok) {
|
||||
await this.loadConfig()
|
||||
this.showToast('Backup restored', 'success')
|
||||
} else {
|
||||
const text = await putRes.text()
|
||||
this.showToast(`Restore failed: ${text}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Restore failed', 'error')
|
||||
}
|
||||
},
|
||||
|
||||
// ── Toast ──
|
||||
showToast(msg, type = 'success') {
|
||||
this.toast = { msg, type }
|
||||
setTimeout(() => { this.toast = null }, 2500)
|
||||
},
|
||||
|
||||
// ── Formatting ──
|
||||
formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
return (bytes / 1024).toFixed(1) + ' KB'
|
||||
},
|
||||
|
||||
formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString()
|
||||
},
|
||||
|
||||
backupNameTime(name) {
|
||||
const m = name.match(/config\.(.+)\.json/)
|
||||
if (!m) return name
|
||||
return m[1].replace('T', ' ')
|
||||
},
|
||||
}))
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,589 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>StreamDeck</title>
|
||||
<script src="/static/app.js"></script>
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="deckApp" x-init="init()" class="app" @keydown.escape="editing = null; subView = null">
|
||||
|
||||
<!-- ═══ Header ═══ -->
|
||||
<header>
|
||||
<button class="icon-btn" @click="view = 'grid'; subView = 'switcher'" title="Switch page">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</button>
|
||||
|
||||
<div class="page-nav" x-show="view === 'grid'">
|
||||
<template x-for="(p, i) in pages" :key="p.name">
|
||||
<span class="dot"
|
||||
:class="{ active: p.name === activePage }"
|
||||
@click="switchPage(p.name)"
|
||||
:title="p.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span x-show="view !== 'grid'" x-text="view === 'settings' ? 'Settings' : 'Backups'"
|
||||
style="font-size:14px;font-weight:600;"></span>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" @click="view = 'grid'; subView = null" title="Grid">
|
||||
<i class="fas fa-table-cells"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="view = 'settings'; subView = null" title="Settings">
|
||||
<i class="fas fa-gear"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="view = 'backups'; loadBackups(); subView = null" title="Backups">
|
||||
<i class="fas fa-floppy-disk"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ Grid View ═══ -->
|
||||
<main x-show="view === 'grid' && !subView">
|
||||
<div class="deck-selector" x-show="decks.length > 1">
|
||||
<template x-for="d in decks" :key="d.serial">
|
||||
<button class="deck-btn"
|
||||
:class="{ active: d.serial === activeDeckSerial }"
|
||||
@click="activeDeckSerial = d.serial"
|
||||
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', minmax(0, 1fr))'">
|
||||
<template x-for="key in gridKeys" :key="key.index">
|
||||
<button class="key-btn"
|
||||
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
|
||||
:class="{ empty: !key.configured }"
|
||||
@click="editKey(key.index)">
|
||||
<img x-show="key.configured && (key.icon || key.label || key.displayText || key.displayBg || key.background)"
|
||||
:src="key.previewUrl" alt="">
|
||||
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;line-height:1;">+</span>
|
||||
<div class="action-icons" x-show="key.hasAction">
|
||||
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
|
||||
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
|
||||
:title="actionTitleMap[t]"></i>
|
||||
</template>
|
||||
<span class="action-icon" x-show="key.actionTypes.length > 3"
|
||||
style="background:var(--text-muted);font-size:7px;font-weight:700;padding:2px 3px;"
|
||||
x-text="'+' + (key.actionTypes.length - 3)"></span>
|
||||
</div>
|
||||
<i x-show="key.hasDisplay" class="fas fa-arrows-rotate display-indicator" title="Periodic display"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-outline" @click="addPage">+ Add page</button>
|
||||
<button class="btn btn-outline" @click="deletePage"
|
||||
:disabled="pages.length <= 1"
|
||||
style="margin-left:auto;">Delete page</button>
|
||||
<button class="btn btn-outline" @click="renamePage">Rename</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Page Switcher (sub-view) ═══ -->
|
||||
<main x-show="view === 'grid' && subView === 'switcher'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
<h3>Pages</h3>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<button class="btn btn-outline" style="display:block;width:100%;margin-bottom:4px;text-align:left;"
|
||||
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
||||
@click="switchPage(p.name); subView = null">
|
||||
<span x-text="p.name"></span>
|
||||
<span x-show="p.dynamic_keys"
|
||||
title="Dynamic keys generator"
|
||||
style="color:var(--accent);font-size:11px;margin-left:4px;">⚡</span>
|
||||
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
||||
x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Settings View ═══ -->
|
||||
<main x-show="view === 'settings'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
|
||||
<!-- Device -->
|
||||
<div class="panel-section">
|
||||
<h4>Device</h4>
|
||||
<div class="form-group">
|
||||
<label>Brightness</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="0" max="100" x-model.number="settingsForm.brightness">
|
||||
<span class="value" x-text="settingsForm.brightness + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Default Font</label>
|
||||
<select x-model="settingsForm.font">
|
||||
<option value="medium">Medium</option>
|
||||
<option value="regular">Regular</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
|
||||
<label for="show-label-bg">Show label background</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screensaver -->
|
||||
<div class="panel-section">
|
||||
<h4>Screensaver</h4>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="ss-enabled" x-model="settingsForm.screensaver_enabled">
|
||||
<label for="ss-enabled">Enable screensaver</label>
|
||||
</div>
|
||||
<div class="form-row" x-show="settingsForm.screensaver_enabled">
|
||||
<div class="form-group">
|
||||
<label>Idle (seconds)</label>
|
||||
<input type="number" min="5" x-model.number="settingsForm.screensaver_idle">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Brightness on idle</label>
|
||||
<input type="number" min="0" max="100" x-model.number="settingsForm.screensaver_brightness">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gesture Timing -->
|
||||
<div class="panel-section">
|
||||
<h4>Gesture Timing</h4>
|
||||
<div class="form-group">
|
||||
<label>Long press threshold</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="200" max="1500" step="50" x-model.number="settingsForm.long_press_ms">
|
||||
<span class="value" x-text="settingsForm.long_press_ms + 'ms'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Double tap threshold</label>
|
||||
<div class="slider-group">
|
||||
<input type="range" min="100" max="600" step="25" x-model.number="settingsForm.double_tap_ms">
|
||||
<span class="value" x-text="settingsForm.double_tap_ms + 'ms'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-switch -->
|
||||
<div class="panel-section">
|
||||
<h4>Auto-switch rules</h4>
|
||||
<template x-for="(rule, i) in autoSwitchRules" :key="i">
|
||||
<div class="rule-item">
|
||||
<div style="flex:1;">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="margin-bottom:4px;">
|
||||
<label>WM Class</label>
|
||||
<input type="text" x-model="rule.wm_class" placeholder="regex">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:4px;">
|
||||
<label>Title</label>
|
||||
<input type="text" x-model="rule.title" placeholder="regex">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Page</label>
|
||||
<select x-model="rule.page">
|
||||
<option value="">--</option>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<option :value="p.name" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
|
||||
<input type="checkbox" x-model="rule.stay">
|
||||
<label>Stay</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="flex-shrink:0;" @click="removeAutoSwitchRule(i)">x</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-outline" @click="addAutoSwitchRule" style="width:100%;margin-top:8px;">
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="saveSettings">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Backup View ═══ -->
|
||||
<main x-show="view === 'backups'" style="justify-content:flex-start;padding-top:12px;">
|
||||
<div class="panel" style="width:100%;">
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Actions</h4>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary" @click="downloadConfig">
|
||||
<i class="fas fa-download" style="margin-right:4px;"></i> Export JSON
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="restoreConfig">
|
||||
<i class="fas fa-upload" style="margin-right:4px;"></i> Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Auto backups <span style="font-weight:400;color:var(--text-muted);"
|
||||
x-text="backups.length ? '(' + backups.length + ')' : ''"></span></h4>
|
||||
<template x-if="backups.length === 0">
|
||||
<p style="color:var(--text-muted);font-size:12px;">No backups yet. They are created automatically when you save.</p>
|
||||
</template>
|
||||
<template x-for="b in backups" :key="b.name">
|
||||
<div class="backup-item">
|
||||
<div>
|
||||
<div x-text="backupNameTime(b.name)"></div>
|
||||
<div class="meta" x-text="formatSize(b.size)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
|
||||
@click="downloadBackup(b.name)" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
|
||||
@click="restoreBackup(b.name)" title="Restore">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ Key Editor Modal ═══ -->
|
||||
<div class="modal-overlay" x-show="editing !== null" @click.self="editing = null">
|
||||
<div class="modal" x-show="editing !== null" x-transition>
|
||||
<h3>Edit Key <span x-text="editing + 1"></span></h3>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-box" @click="pickImage" title="Click to upload image">
|
||||
<img :src="editPreviewUrl" alt="">
|
||||
<div class="preview-overlay">
|
||||
<i class="fas fa-image"></i>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="image/*" id="image-picker" style="display:none"
|
||||
x-ref="fileInput" @change="onImagePicked">
|
||||
|
||||
<!-- Basic fields -->
|
||||
<div class="form-group">
|
||||
<label>Icon</label>
|
||||
<input type="text" x-model="editForm.icon"
|
||||
placeholder="fa:terminal, @system-icon, /path/to/image.png">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Label</label>
|
||||
<input type="text" x-model="editForm.label" placeholder="Optional text">
|
||||
</div>
|
||||
|
||||
<!-- Actions section -->
|
||||
<h4 style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;margin-top:12px;">Actions</h4>
|
||||
|
||||
<!-- Action tabs -->
|
||||
<div class="action-tabs">
|
||||
<template x-for="t in ['tap','long_press','double_tap','hold']" :key="t">
|
||||
<button class="action-tab"
|
||||
:class="{ active: editForm.activeTrigger === t, configured: (t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t)) }"
|
||||
@click="editForm.activeTrigger = t">
|
||||
<span x-text="triggerLabel(t)"></span>
|
||||
<span class="tab-check" x-show="(t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t))">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Non-hold trigger panes -->
|
||||
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
|
||||
<div x-show="editForm.activeTrigger === t">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions[t].type">
|
||||
<option value="none">None</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="builtin">Builtin</option>
|
||||
<option value="script">Script</option>
|
||||
<option value="page">Switch page</option>
|
||||
<option value="keyboard">Keyboard shortcut</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'command'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions[t].command" placeholder="firefox">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" :id="'bg-cb-' + t" x-model="editForm.actions[t].background">
|
||||
<label :for="'bg-cb-' + t">Run in background</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'builtin'">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions[t].builtin">
|
||||
<option value="">-- select --</option>
|
||||
<option value="volume_up">Volume Up</option>
|
||||
<option value="volume_down">Volume Down</option>
|
||||
<option value="volume_mute">Volume Mute</option>
|
||||
<option value="brightness_up">Brightness Up</option>
|
||||
<option value="brightness_down">Brightness Down</option>
|
||||
<option value="media_play_pause">Play/Pause</option>
|
||||
<option value="media_next">Next Track</option>
|
||||
<option value="media_prev">Previous Track</option>
|
||||
<option value="media_stop">Stop</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'script'">
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions[t].script" placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
|
||||
<select x-model="editForm.actions[t].page">
|
||||
<option value="">-- select --</option>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<option :value="p.name" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions[t].type === 'keyboard'">
|
||||
<div class="form-group">
|
||||
<label>Key combination</label>
|
||||
<div class="mod-row">
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions[t].modSuper }" @click="toggleMod('super')" type="button">Super</button>
|
||||
</div>
|
||||
<div class="key-capture"
|
||||
x-ref="keyCapture"
|
||||
:class="{ capturing: isCapturingKey }"
|
||||
tabindex="0"
|
||||
@keydown.prevent="onCaptureKey"
|
||||
@blur="isCapturingKey = false"
|
||||
@click="startKeyCapture">
|
||||
<span x-show="!isCapturingKey && editForm.actions[t].mainKey" class="captured-key" x-text="displayKey(editForm.actions[t].mainKey)"></span>
|
||||
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||
<span x-show="!isCapturingKey && !editForm.actions[t].mainKey" class="capture-placeholder">Click to capture</span>
|
||||
</div>
|
||||
<div class="key-chips" x-show="editForm.actions[t].keys" style="margin-top:8px;">
|
||||
<template x-for="(chip, ki) in keyChips" :key="ki">
|
||||
<span class="key-chip" x-text="chip"></span>
|
||||
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Hold pane -->
|
||||
<div x-show="editForm.activeTrigger === 'hold'">
|
||||
<div class="hold-subtabs">
|
||||
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'start' }"
|
||||
@click="editForm.holdPhase = 'start'">On press & hold</button>
|
||||
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'end' }"
|
||||
@click="editForm.holdPhase = 'end'">On release</button>
|
||||
</div>
|
||||
|
||||
<template x-for="phase in ['start','end']" :key="phase">
|
||||
<div x-show="editForm.holdPhase === phase">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions.hold[phase].type">
|
||||
<option value="none">None</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="builtin">Builtin</option>
|
||||
<option value="script">Script</option>
|
||||
<option value="page">Switch page</option>
|
||||
<option value="keyboard">Keyboard shortcut</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'command'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions.hold[phase].command" placeholder="firefox">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" :id="'hold-bg-' + phase" x-model="editForm.actions.hold[phase].background">
|
||||
<label :for="'hold-bg-' + phase">Run in background</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
|
||||
<div class="form-group">
|
||||
<select x-model="editForm.actions.hold[phase].builtin">
|
||||
<option value="">-- select --</option>
|
||||
<option value="volume_up">Volume Up</option>
|
||||
<option value="volume_down">Volume Down</option>
|
||||
<option value="volume_mute">Volume Mute</option>
|
||||
<option value="brightness_up">Brightness Up</option>
|
||||
<option value="brightness_down">Brightness Down</option>
|
||||
<option value="media_play_pause">Play/Pause</option>
|
||||
<option value="media_next">Next Track</option>
|
||||
<option value="media_prev">Previous Track</option>
|
||||
<option value="media_stop">Stop</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'script'">
|
||||
<div class="form-group">
|
||||
<input type="text" x-model="editForm.actions.hold[phase].script" placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
|
||||
<select x-model="editForm.actions.hold[phase].page">
|
||||
<option value="">-- select --</option>
|
||||
<template x-for="p in pages" :key="p.name">
|
||||
<option :value="p.name" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">
|
||||
<div class="form-group">
|
||||
<label>Key combination</label>
|
||||
<div class="mod-row">
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modSuper }" @click="toggleMod('super')" type="button">Super</button>
|
||||
</div>
|
||||
<div class="key-capture"
|
||||
x-ref="keyCapture"
|
||||
:class="{ capturing: isCapturingKey }"
|
||||
tabindex="0"
|
||||
@keydown.prevent="onCaptureKey"
|
||||
@blur="isCapturingKey = false"
|
||||
@click="startKeyCapture">
|
||||
<span x-show="!isCapturingKey && editForm.actions.hold[phase].mainKey" class="captured-key" x-text="displayKey(editForm.actions.hold[phase].mainKey)"></span>
|
||||
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||
<span x-show="!isCapturingKey && !editForm.actions.hold[phase].mainKey" class="capture-placeholder">Click to capture</span>
|
||||
</div>
|
||||
<div class="key-chips" x-show="editForm.actions.hold[phase].keys" style="margin-top:8px;">
|
||||
<template x-for="(chip, ki) in keyChips" :key="ki">
|
||||
<span class="key-chip" x-text="chip"></span>
|
||||
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Advanced toggle -->
|
||||
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced" style="margin-top:8px;">
|
||||
<i class="fas fa-chevron-right"></i> More
|
||||
</button>
|
||||
|
||||
<!-- Advanced fields -->
|
||||
<div x-show="showAdvanced" x-transition>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Icon Scale</label>
|
||||
<input type="number" step="0.01" min="0.1" max="1.0" x-model.number="editForm.icon_scale">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Font Size</label>
|
||||
<input type="number" step="0.5" min="8" max="32" x-model.number="editForm.font_size">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Background</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="color"
|
||||
x-init="$el.value = editForm.bg_color || '#000000'; $el.addEventListener('input', () => editForm.bg_color = $el.value); $watch('editForm.bg_color', v => $el.value = v || '#000000')"
|
||||
style="width:40px;height:40px;padding:2px;border-radius:var(--radius-xs);cursor:pointer;">
|
||||
<input type="text" x-model="editForm.bg_color"
|
||||
placeholder="#rrggbb or #rrggbbaa" maxlength="9"
|
||||
style="flex:1;">
|
||||
<button class="icon-btn" style="width:24px;height:24px;font-size:10px;flex-shrink:0;"
|
||||
@click="editForm.bg_color = ''" title="Clear"
|
||||
x-show="editForm.bg_color !== ''">
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Display mode</label>
|
||||
<div class="tab-row">
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'none' }"
|
||||
@click="editForm.display_mode = 'none'">Off</button>
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'command' }"
|
||||
@click="editForm.display_mode = 'command'">Command</button>
|
||||
<button class="tab-btn" :class="{ active: editForm.display_mode === 'script' }"
|
||||
@click="editForm.display_mode = 'script'">Script</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="editForm.display_mode === 'command'">
|
||||
<div class="form-group">
|
||||
<label>Command</label>
|
||||
<input type="text" x-model="editForm.display_command"
|
||||
placeholder="curl -s http://...">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.display_mode === 'script'">
|
||||
<div class="form-group">
|
||||
<label>Script path</label>
|
||||
<input type="text" x-model="editForm.display_script"
|
||||
placeholder="/path/to/script.sh">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="editForm.display_mode !== 'none'">
|
||||
<div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Interval</label>
|
||||
<input type="text" x-model="editForm.display_interval" placeholder="30s">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Timeout</label>
|
||||
<input type="text" x-model="editForm.display_timeout" placeholder="30s">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max output length</label>
|
||||
<input type="number" x-model.number="editForm.display_max_len" min="16" max="4096" placeholder="128">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="saveKey">Save</button>
|
||||
<button class="btn btn-danger" @click="deleteKey">Delete</button>
|
||||
<button class="btn btn-outline" @click="editing = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Toast ═══ -->
|
||||
<div class="toast" :class="toast?.type" x-show="toast" x-transition x-text="toast?.msg"></div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,813 @@
|
||||
:root {
|
||||
--bg: #0a0a10;
|
||||
--surface: #141420;
|
||||
--surface-hover: #1a1a2a;
|
||||
--border: #252540;
|
||||
--border-light: #333358;
|
||||
--text: #e4e4f0;
|
||||
--text-muted: #7a7a90;
|
||||
--accent: #6366f1;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 6px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.page-nav .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.page-nav .dot:hover { background: var(--text-muted); }
|
||||
.page-nav .dot.active { background: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
font-size: 14px;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deck-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
max-width: 525px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.deck-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.deck-btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-align: center;
|
||||
}
|
||||
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.deck-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #0d0d18;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all var(--transition);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.key-btn:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.key-btn:active { transform: scale(0.97); }
|
||||
|
||||
.key-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.key-btn.empty {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.key-btn.empty:hover {
|
||||
opacity: 0.5;
|
||||
border-color: var(--border-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
font-size: 9px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fa-preview {
|
||||
font-size: 34px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: var(--shadow);
|
||||
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(16px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
background: #0d0d18;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.preview-box:hover .preview-overlay { opacity: 1; }
|
||||
|
||||
.preview-overlay i {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-row label {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Collapsible */
|
||||
.collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.collapse-toggle:hover { color: var(--accent); }
|
||||
.collapse-toggle i { font-size: 10px; transition: transform var(--transition); }
|
||||
.collapse-toggle.open i { transform: rotate(90deg); }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ── Settings / Backup panels ── */
|
||||
.panel {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.panel-section h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.backup-item:last-child { border-bottom: none; }
|
||||
|
||||
.backup-item .meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-badge .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge .dot.online { background: var(--success); }
|
||||
.status-badge .dot.offline { background: var(--text-muted); }
|
||||
|
||||
/* ── Toasts ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
z-index: 200;
|
||||
box-shadow: var(--shadow);
|
||||
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
.slider-group .value {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar .btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||
|
||||
/* ── Auto-switch rule item ── */
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-xs);
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rule-item code {
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Indicators on grid keys ── */
|
||||
.display-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-icons {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
pointer-events: none;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100% - 6px);
|
||||
}
|
||||
.action-icon {
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Tab buttons ── */
|
||||
.tab-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-btn:last-child { border-right: none; }
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Modifier buttons ── */
|
||||
.mod-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mod-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-align: center;
|
||||
}
|
||||
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.mod-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Key capture ── */
|
||||
.key-capture {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 8px;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.key-capture:hover { border-color: var(--accent); }
|
||||
.key-capture:focus-visible { border-color: var(--accent); }
|
||||
.key-capture.capturing {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
animation: recorderPulse 1s infinite;
|
||||
}
|
||||
.captured-key {
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
.capture-hint { color: var(--accent); font-weight: 600; }
|
||||
.capture-placeholder { color: var(--text-muted); opacity: 0.6; }
|
||||
|
||||
@keyframes recorderPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.key-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.key-chip {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
.key-plus {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* ── Action tabs ── */
|
||||
.action-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.action-tab {
|
||||
flex: 1;
|
||||
padding: 5px 2px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.action-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.action-tab.configured {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
.action-tab.active.configured {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tab-check { font-size: 8px; }
|
||||
|
||||
/* ── Hold sub-tabs ── */
|
||||
.hold-subtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hold-stab {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.hold-stab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var staticRoot fs.FS
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
staticRoot, err = fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("static embed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
type WebServer struct {
|
||||
cfg *config.Config
|
||||
configPath string
|
||||
pm *render.PageManager
|
||||
extraPMs []*render.PageManager
|
||||
decks []*deck.Deck
|
||||
mu sync.RWMutex
|
||||
|
||||
sseClients map[chan string]struct{}
|
||||
sseMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
|
||||
return &WebServer{
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
sseClients: make(map[chan string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) UpdateConfig(cfg *config.Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg = cfg
|
||||
}
|
||||
|
||||
func (s *WebServer) SetPageManager(pm *render.PageManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pm = pm
|
||||
}
|
||||
|
||||
func (s *WebServer) SetDecks(decks []*deck.Deck) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.decks = decks
|
||||
}
|
||||
|
||||
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.extraPMs = pms
|
||||
}
|
||||
|
||||
func (s *WebServer) BroadcastPageChange(page string) {
|
||||
s.sseMu.RLock()
|
||||
defer s.sseMu.RUnlock()
|
||||
for ch := range s.sseClients {
|
||||
select {
|
||||
case ch <- page:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) Serve(ctx context.Context, addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/config", s.handleGetConfig)
|
||||
mux.HandleFunc("PUT /api/config", s.handlePutConfig)
|
||||
mux.HandleFunc("GET /api/config/download", s.handleDownloadConfig)
|
||||
mux.HandleFunc("POST /api/config/restore", s.handleRestoreConfig)
|
||||
|
||||
mux.HandleFunc("GET /api/pages", s.handleGetPages)
|
||||
|
||||
mux.HandleFunc("GET /api/render", s.handleRender)
|
||||
|
||||
mux.HandleFunc("GET /api/display-outputs", s.handleDisplayOutputs)
|
||||
|
||||
mux.HandleFunc("GET /api/backups", s.handleListBackups)
|
||||
mux.HandleFunc("GET /api/backups/{filename}", s.handleGetBackup)
|
||||
|
||||
mux.HandleFunc("GET /api/models", s.handleGetModels)
|
||||
|
||||
mux.HandleFunc("GET /api/decks", s.handleGetDecks)
|
||||
|
||||
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||
|
||||
mux.HandleFunc("GET /api/events", s.handleSSE)
|
||||
mux.HandleFunc("POST /api/activate-page", s.handleActivatePage)
|
||||
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
||||
|
||||
mux.HandleFunc("/", s.handleSPA)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/api/upload" {
|
||||
s.handleUpload(w, r)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: handler}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down web server")
|
||||
srv.Close()
|
||||
}()
|
||||
|
||||
slog.Info("web server listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("web server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebServer) handleSPA(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
data, err := fs.ReadFile(staticRoot, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := make(chan string, 8)
|
||||
|
||||
s.sseMu.Lock()
|
||||
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
|
||||
s.sseMu.Lock()
|
||||
delete(s.sseClients, ch)
|
||||
s.sseMu.Unlock()
|
||||
}()
|
||||
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case page := <-ch:
|
||||
data, _ := json.Marshal(map[string]string{"page": page})
|
||||
fmt.Fprintf(w, "event: page_changed\ndata: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Page string `json:"page"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Page == "" {
|
||||
http.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, pm := range allPMs {
|
||||
if pm == nil {
|
||||
continue
|
||||
}
|
||||
if err := pm.ActivatePage(req.Page); err != nil {
|
||||
slog.Warn("activate page", "page", req.Page, "error", err)
|
||||
} else {
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
}
|
||||
|
||||
s.BroadcastPageChange(req.Page)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "page": req.Page})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(s.cfg)
|
||||
}
|
||||
|
||||
func (s *WebServer) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var newCfg config.Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := newCfg.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
|
||||
s.mu.Lock()
|
||||
oldCfg := s.cfg
|
||||
s.cfg = &newCfg
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.cfg.Save(s.configPath); err != nil {
|
||||
s.mu.Lock()
|
||||
s.cfg = oldCfg
|
||||
s.mu.Unlock()
|
||||
slog.Error("save config", "error", err)
|
||||
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
backupOldConfig(path)
|
||||
gcImages(&newCfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "saved"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleDownloadConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, "serialization error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("streamdeck-config-%s.json", time.Now().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRestoreConfig(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var newCfg config.Config
|
||||
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := json.NewDecoder(file).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := newCfg.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupOldConfig(path)
|
||||
|
||||
s.mu.Lock()
|
||||
s.cfg = &newCfg
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.cfg.Save(s.configPath); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
gcImages(&newCfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "restored"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type pageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Keys []config.KeyConfig `json:"keys"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DynamicKeys *config.DynamicKeyGen `json:"dynamic_keys,omitempty"`
|
||||
Background string `json:"background,omitempty"`
|
||||
EffectiveKeys []config.KeyConfig `json:"effective_keys,omitempty"`
|
||||
}
|
||||
|
||||
pages := make([]pageInfo, 0, len(s.cfg.Pages))
|
||||
for _, p := range s.cfg.Pages {
|
||||
pi := pageInfo{
|
||||
Name: p.Name,
|
||||
Keys: p.Keys,
|
||||
Icon: p.Icon,
|
||||
DynamicKeys: p.DynamicKeys,
|
||||
Background: p.Background,
|
||||
}
|
||||
if p.DynamicKeys != nil && s.pm != nil {
|
||||
pi.EffectiveKeys = s.pm.GetEffectiveKeys(p.Name)
|
||||
}
|
||||
pages = append(pages, pi)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(pages)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
keySize := 72
|
||||
if s := q.Get("key_size"); s != "" {
|
||||
fmt.Sscanf(s, "%d", &keySize)
|
||||
}
|
||||
|
||||
kc := &config.KeyConfig{}
|
||||
|
||||
if page := q.Get("page"); page != "" {
|
||||
key := q.Get("key")
|
||||
s.mu.RLock()
|
||||
for _, p := range s.cfg.Pages {
|
||||
if p.Name == page {
|
||||
for _, k := range p.Keys {
|
||||
if fmt.Sprintf("%d", k.Index) == key {
|
||||
kc = &k
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
if icon := q.Get("icon"); icon != "" {
|
||||
kc.Icon = icon
|
||||
if s := q.Get("icon_scale"); s != "" {
|
||||
v := 0.0
|
||||
fmt.Sscanf(s, "%f", &v)
|
||||
kc.IconScale = &v
|
||||
}
|
||||
}
|
||||
if label := q.Get("label"); label != "" {
|
||||
kc.Label = label
|
||||
}
|
||||
if fs := q.Get("font_size"); fs != "" {
|
||||
v := 0.0
|
||||
fmt.Sscanf(fs, "%f", &v)
|
||||
kc.FontSize = &v
|
||||
}
|
||||
if bg := q.Get("background"); bg != "" {
|
||||
kc.Background = bg
|
||||
}
|
||||
|
||||
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
png.Encode(w, img)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleDisplayOutputs(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
pm := s.pm
|
||||
s.mu.RUnlock()
|
||||
|
||||
if pm == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
|
||||
outputs := pm.GetDisplayOutputs()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(outputs)
|
||||
}
|
||||
|
||||
func backupOldConfig(path string) {
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
slog.Warn("backup: create dir", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("config.%s.json", time.Now().Format("2006-01-02T15-04-05"))
|
||||
dst := filepath.Join(backupDir, name)
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
slog.Warn("backup: write", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(backupDir)
|
||||
if len(entries) > 10 {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
fi, _ := entries[i].Info()
|
||||
fj, _ := entries[j].Info()
|
||||
return fi.ModTime().Before(fj.ModTime())
|
||||
})
|
||||
for _, e := range entries[:len(entries)-10] {
|
||||
os.Remove(filepath.Join(backupDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectIcons(cfg *config.Config, used map[string]bool) {
|
||||
for _, p := range cfg.Pages {
|
||||
for _, k := range p.Keys {
|
||||
if k.Icon != "" {
|
||||
used[k.Icon] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gcImages(cfg *config.Config) {
|
||||
imgDir := filepath.Join(config.ConfigDir(), "images")
|
||||
entries, err := os.ReadDir(imgDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
used := make(map[string]bool)
|
||||
collectIcons(cfg, used)
|
||||
|
||||
backupDir := filepath.Join(config.ConfigDir(), "backups")
|
||||
backupEntries, _ := os.ReadDir(backupDir)
|
||||
for _, be := range backupEntries {
|
||||
if be.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(backupDir, be.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var backupCfg config.Config
|
||||
if err := json.Unmarshal(data, &backupCfg); err != nil {
|
||||
continue
|
||||
}
|
||||
collectIcons(&backupCfg, used)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(imgDir, e.Name())
|
||||
if !used[path] {
|
||||
if err := os.Remove(path); err != nil {
|
||||
slog.Warn("gc: remove orphan image", "path", path, "error", err)
|
||||
} else {
|
||||
slog.Debug("gc: removed orphan image", "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
type backupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
var backups []backupInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "config.") || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, backupInfo{
|
||||
Name: e.Name(),
|
||||
Size: fi.Size(),
|
||||
Time: fi.ModTime().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Time > backups[j].Time
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(backups)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "..") {
|
||||
http.Error(w, "invalid filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := config.ConfigPath(s.configPath)
|
||||
backupDir := filepath.Join(filepath.Dir(path), "backups")
|
||||
fullPath := filepath.Join(backupDir, filename)
|
||||
|
||||
if !strings.HasPrefix(fullPath, backupDir) {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
http.Error(w, "backup not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
|
||||
type modelInfo struct {
|
||||
PID uint16 `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
KeysX int `json:"keys_x"`
|
||||
KeysY int `json:"keys_y"`
|
||||
KeySize int `json:"key_size"`
|
||||
}
|
||||
|
||||
models := make([]modelInfo, 0, len(deck.KnownDecks))
|
||||
for _, d := range deck.KnownDecks {
|
||||
models = append(models, modelInfo{
|
||||
PID: d.PID,
|
||||
Name: d.Name,
|
||||
KeysX: d.KeysX,
|
||||
KeysY: d.KeysY,
|
||||
KeySize: d.KeySize,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
infos := make([]deck.DeckInfo, 0, len(s.decks))
|
||||
for _, d := range s.decks {
|
||||
infos = append(infos, d.DeckInfo())
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(infos)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"config_version": s.cfg.Version,
|
||||
"default_page": s.cfg.DefaultPage,
|
||||
"page_count": len(s.cfg.Pages),
|
||||
"device_count": len(s.cfg.Devices),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
||||
savePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dst, err := os.Create(savePath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("create file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, fmt.Sprintf("write file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": savePath})
|
||||
}
|
||||
Reference in New Issue
Block a user