Compare commits
16
Commits
main
..
1d10191d90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d10191d90 | ||
|
|
e8b4eafc2e | ||
|
|
1a63da9171 | ||
|
|
56a5cb5bba | ||
|
|
cdb30bec3f | ||
|
|
95e96e7514 | ||
|
|
af7774c14f | ||
|
|
773dc7350a | ||
|
|
fe8ca312c4 | ||
|
|
44ad064488 | ||
|
|
ae693f7bf7 | ||
|
|
e3eb1a9174 | ||
|
|
b075b72eea | ||
|
|
52d4665e87 | ||
|
|
d5998e81cf | ||
|
|
f65ff75a43 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/streamdeck-lets-go
|
streamdeck-lets-go
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024-2026 Wakatron
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
BINARY := streamdeck-lets-go
|
|
||||||
MODULE := streamdeck-lets-go
|
|
||||||
CMDDIR := cmd/streamdeck-lets-go
|
|
||||||
PKGARCH := packaging
|
|
||||||
|
|
||||||
.PHONY: all build test vet fmt clean install
|
|
||||||
|
|
||||||
all: build
|
|
||||||
|
|
||||||
build:
|
|
||||||
CGO_ENABLED=1 go build -ldflags "-s -w" -o $(BINARY) ./$(CMDDIR)
|
|
||||||
|
|
||||||
test:
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
vet:
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
gofmt -l .
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f $(BINARY)
|
|
||||||
|
|
||||||
install: build
|
|
||||||
install -Dm755 $(BINARY) /usr/local/bin/$(BINARY)
|
|
||||||
install -Dm644 $(PKGARCH)/streamdeck-lets-go.service /usr/lib/systemd/user/streamdeck-lets-go.service
|
|
||||||
install -Dm644 $(PKGARCH)/90-streamdeck.rules /usr/lib/udev/rules.d/90-streamdeck.rules
|
|
||||||
|
|
||||||
uninstall:
|
|
||||||
rm -f /usr/local/bin/$(BINARY)
|
|
||||||
rm -f /usr/lib/systemd/user/streamdeck-lets-go.service
|
|
||||||
rm -f /usr/lib/udev/rules.d/90-streamdeck.rules
|
|
||||||
@@ -27,9 +27,7 @@
|
|||||||
### Install from source
|
### Install from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
go build -o streamdeck-lets-go .
|
||||||
# or
|
|
||||||
go build -o streamdeck-lets-go ./cmd/streamdeck-lets-go
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
@@ -49,19 +47,7 @@ Open `http://localhost:9090` in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project structure
|
## Configuration
|
||||||
|
|
||||||
```
|
|
||||||
cmd/streamdeck-lets-go entry point (CLI: daemon, switch, discover)
|
|
||||||
internal/
|
|
||||||
config JSON config model, load/save/validate
|
|
||||||
deck Stream Deck hardware: open, render, brightness, events
|
|
||||||
render Key rendering, icon/fonts, PageManager, display outputs
|
|
||||||
web HTTP API + embedded Alpine.js SPA
|
|
||||||
daemon Event loop, autoswitch, screensaver, actions, gestures
|
|
||||||
packaging/ AUR PKGBUILD, systemd unit, udev rule
|
|
||||||
Makefile build, test, vet, fmt, install
|
|
||||||
```
|
|
||||||
|
|
||||||
The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI.
|
The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI.
|
||||||
|
|
||||||
@@ -141,213 +127,6 @@ Server down!
|
|||||||
|
|
||||||
If no color is specified, the key uses its configured background or the default black.
|
If no color is specified, the key uses its configured background or the default black.
|
||||||
|
|
||||||
### Dynamic Key Generators
|
|
||||||
|
|
||||||
Generate an entire page of keys dynamically by running any executable (shell script, Python, Go binary, etc.). Useful for game launchers, music collections, Docker containers, monitoring dashboards — anything where the set of keys is not known at config time.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ Generator script (any language) │
|
|
||||||
│ → queries Lutris DB, Steam API, etc. │
|
|
||||||
│ → outputs JSON array of KeyConfig to stdout │
|
|
||||||
│ → receives STREAMDECK_KEY_COUNT env var │
|
|
||||||
└──────────────┬───────────────────────────────────┘
|
|
||||||
│ stdout: [{"index":0,"icon":"...",...}]
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ streamdeck-lets-go daemon │
|
|
||||||
│ → parses JSON │
|
|
||||||
│ → merges with static keys (static wins) │
|
|
||||||
│ → renders keys on the deck │
|
|
||||||
│ → re-runs on interval (default 60s) │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Config
|
|
||||||
|
|
||||||
Add `dynamic_keys` to any page:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Games",
|
|
||||||
"icon": "fa:gamepad",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"icon": "fa:arrow-left",
|
|
||||||
"actions": [
|
|
||||||
{ "trigger": "tap", "type": "page", "page": "main" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dynamic_keys": {
|
|
||||||
"command": "/home/user/bin/game-list",
|
|
||||||
"interval": "120s",
|
|
||||||
"timeout": "15s",
|
|
||||||
"max_keys": 14
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `command` | string | — | Shell command to execute (mutually exclusive with `script`) |
|
|
||||||
| `script` | string | — | Path to an executable script; relative paths resolve against `~/.config/streamdeck-lets-go/` |
|
|
||||||
| `interval` | string | `"60s"` | How often to re-run the generator (minimum 1s, maximum 24h) |
|
|
||||||
| `timeout` | string | `"15s"` | Maximum execution time before the generator is killed |
|
|
||||||
| `max_keys` | int | `deck.NumKeys()` | Maximum number of keys to accept (e.g. `13` to reserve 2 for navigation) |
|
|
||||||
|
|
||||||
#### Generator contract
|
|
||||||
|
|
||||||
The script **must** print a JSON array of key configs to stdout and exit with code 0. Each element supports the full `KeyConfig` schema:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"icon": "/home/user/.local/share/lutris/coverart/cyberpunk-2077.jpg",
|
|
||||||
"label": "Cyberpunk 2077",
|
|
||||||
"font_size": 12,
|
|
||||||
"icon_scale": 1.0,
|
|
||||||
"background": "#222222",
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"trigger": "tap",
|
|
||||||
"type": "command",
|
|
||||||
"command": "lutris lutris:rungame/cyberpunk-2077",
|
|
||||||
"background": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Every field from a static key is available:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `index` | int | Key position on the deck (0-based) |
|
|
||||||
| `icon` | string | Icon source: `fa:terminal`, `emoji:fire`, `@firefox`, `/absolute/path.png`, relative path |
|
|
||||||
| `label` | string | Text shown below the icon |
|
|
||||||
| `font_size` | number | Font size in points (default 18, recommended 11–14 for labels) |
|
|
||||||
| `icon_scale` | number | Icon scale factor (0.0–1.0, default 0.55) |
|
|
||||||
| `background` | string | Hex background color `"#222222"` |
|
|
||||||
| `actions` | array | Same action schema as static keys — supports all triggers (`tap`, `long_press`, `double_tap`) and all action types (`command`, `builtin`, `script`, `page`, `keyboard`) |
|
|
||||||
| `display` | object | Periodic display block (same as static key `display`) — command/script runs on interval and text is overlaid on the key |
|
|
||||||
|
|
||||||
#### Merge behavior
|
|
||||||
|
|
||||||
Static `keys` (defined directly on the page) and dynamic keys are **merged** at runtime:
|
|
||||||
|
|
||||||
| Priority | Source |
|
|
||||||
|---|---|
|
|
||||||
| 1 (highest) | Static keys — their indices are reserved |
|
|
||||||
| 2 | Dynamic keys — fill indices not occupied by static keys |
|
|
||||||
|
|
||||||
This lets you keep navigation buttons fixed while the generator fills the rest of the deck:
|
|
||||||
|
|
||||||
```
|
|
||||||
Static: [← back] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
|
|
||||||
index 0 └────────────────────── dynamic (indices 1–14) ──────────────────────→
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Environment variables
|
|
||||||
|
|
||||||
The generator receives these environment variables:
|
|
||||||
|
|
||||||
| Variable | Example | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `STREAMDECK_KEY_COUNT` | `15` | Maximum number of keys to output (derived from deck model or `max_keys` config) |
|
|
||||||
| `STREAMDECK_CONFIG_DIR` | `/home/user/.config/streamdeck-lets-go` | Path to the config directory — useful for finding companion files |
|
|
||||||
|
|
||||||
#### Error handling
|
|
||||||
|
|
||||||
| Situation | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| Script fails (non-zero exit, timeout) | Warning logged; previous keys preserved |
|
|
||||||
| Script returns empty array `[]` | Only static keys rendered |
|
|
||||||
| Script returns invalid JSON | Warning logged; previous keys preserved |
|
|
||||||
| Script outputs debug to stderr | Logged at DEBUG level; stdout JSON parsed cleanly |
|
|
||||||
| More keys than `max_keys` | Excess keys silently dropped |
|
|
||||||
| Generator updates while page is active | Keys re-rendered in place (no flicker) |
|
|
||||||
|
|
||||||
#### Examples
|
|
||||||
|
|
||||||
**Python — Lutris game launcher**
|
|
||||||
|
|
||||||
Place this at `~/.config/streamdeck-lets-go/scripts/lutris-keys`, make executable (`chmod +x`).
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
import json, os, sqlite3, pathlib
|
|
||||||
|
|
||||||
db = pathlib.Path.home() / '.local/share/lutris/pga.db'
|
|
||||||
cover = pathlib.Path.home() / '.local/share/lutris/coverart'
|
|
||||||
max_keys = int(os.environ.get('STREAMDECK_KEY_COUNT', 15))
|
|
||||||
|
|
||||||
if not db.exists():
|
|
||||||
print('[]')
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db)
|
|
||||||
games = conn.execute(
|
|
||||||
"SELECT slug, name FROM games WHERE installed = 1 ORDER BY name"
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
keys = []
|
|
||||||
for idx, (slug, name) in enumerate(games):
|
|
||||||
if idx >= max_keys:
|
|
||||||
break
|
|
||||||
icon = next(cover.glob(f'{slug}.*'), None)
|
|
||||||
key = {
|
|
||||||
"index": idx,
|
|
||||||
"icon_scale": 1.0,
|
|
||||||
"actions": [{
|
|
||||||
"trigger": "tap",
|
|
||||||
"type": "command",
|
|
||||||
"command": f"lutris lutris:rungame/{slug}",
|
|
||||||
"background": True
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
if icon:
|
|
||||||
key["icon"] = str(icon)
|
|
||||||
keys.append(key)
|
|
||||||
|
|
||||||
print(json.dumps(keys))
|
|
||||||
```
|
|
||||||
|
|
||||||
Reference it in config:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Games",
|
|
||||||
"dynamic_keys": {
|
|
||||||
"script": "scripts/lutris-keys",
|
|
||||||
"interval": "120s"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Shell — Simple test generator**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/sh
|
|
||||||
for i in $(seq 0 $((STREAMDECK_KEY_COUNT - 1))); do
|
|
||||||
[ "$i" -gt 0 ] && echo ","
|
|
||||||
printf '{"index":%d,"icon":"fa:hashtag","label":"Item %d"}' "$i" "$i"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Run with any language you like — the only requirement is a valid JSON array on stdout.
|
|
||||||
|
|
||||||
#### Tips
|
|
||||||
|
|
||||||
- Use `max_keys` to reserve space for static navigation buttons
|
|
||||||
- Enable `show_label_background: true` in the global config if labels on full-bleed icons are hard to read
|
|
||||||
- Test your generator standalone before wiring it up: `STREAMDECK_KEY_COUNT=15 ./my-generator`
|
|
||||||
- For large data sources, pass pagination params through the command string: `"command": "scripts/albums --page 0"`
|
|
||||||
- Dynamic keys support `display` blocks — nest monitoring data inside generated keys
|
|
||||||
|
|
||||||
### Gesture timing
|
### Gesture timing
|
||||||
|
|
||||||
Configured in the web UI (Settings → Gesture Timing) or in `config.json`:
|
Configured in the web UI (Settings → Gesture Timing) or in `config.json`:
|
||||||
@@ -385,9 +164,9 @@ After a configurable idle period the deck dims or shows a custom image.
|
|||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/totmin/streamdeck-lets-go.git
|
git clone git@git.totmin.ru:en2zmax/streamdeck-lets-go.git
|
||||||
cd streamdeck-lets-go
|
cd streamdeck-lets-go
|
||||||
make build
|
go build -o streamdeck-lets-go .
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies:
|
Dependencies:
|
||||||
@@ -412,16 +191,6 @@ Dependencies:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
This project builds on the work of the following open-source libraries:
|
|
||||||
|
|
||||||
- **[dh1tw/streamdeck](https://github.com/dh1tw/streamdeck)** (MIT) — Go protocol driver for Elgato Stream Deck devices
|
|
||||||
- **[bearsh/hid](https://github.com/bearsh/hid)** (MIT) — Go USB HID bindings via CGO / libusb
|
|
||||||
- **[disintegration/gift](https://github.com/disintegration/gift)** (MIT) — Go Image Filtering Toolkit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
"streamdeck-lets-go/internal/render"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
|
func ExecuteAction(a *config.Action, deck *Deck, pm *PageManager) error {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +74,32 @@ func execScript(a *config.Action) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func execBuiltin(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
|
func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if d.Command != "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, d.Script)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
output := stdout.String()
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
if output != "" {
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
output += stderr.String()
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error {
|
||||||
parts := strings.SplitN(a.Builtin, ":", 2)
|
parts := strings.SplitN(a.Builtin, ":", 2)
|
||||||
if len(parts) < 1 {
|
if len(parts) < 1 {
|
||||||
return fmt.Errorf("invalid builtin: %s", a.Builtin)
|
return fmt.Errorf("invalid builtin: %s", a.Builtin)
|
||||||
@@ -151,10 +177,10 @@ func brightnessAction(action string) error {
|
|||||||
return exec.Command("sh", "-c", cmd).Run()
|
return exec.Command("sh", "-c", cmd).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pageBuiltinAction(action string, pm *render.PageManager) error {
|
func pageBuiltinAction(action string, pm *PageManager) error {
|
||||||
switch action {
|
switch action {
|
||||||
case "next":
|
case "next":
|
||||||
pageNames := pm.GetPageNames()
|
pageNames := pm.PageNames()
|
||||||
current := pm.ActivePageName()
|
current := pm.ActivePageName()
|
||||||
for i, name := range pageNames {
|
for i, name := range pageNames {
|
||||||
if name == current {
|
if name == current {
|
||||||
@@ -166,7 +192,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
|
|||||||
return pm.ActivatePage(pageNames[0])
|
return pm.ActivatePage(pageNames[0])
|
||||||
}
|
}
|
||||||
case "prev":
|
case "prev":
|
||||||
pageNames := pm.GetPageNames()
|
pageNames := pm.PageNames()
|
||||||
current := pm.ActivePageName()
|
current := pm.ActivePageName()
|
||||||
for i, name := range pageNames {
|
for i, name := range pageNames {
|
||||||
if name == current {
|
if name == current {
|
||||||
@@ -183,7 +209,7 @@ func pageBuiltinAction(action string, pm *render.PageManager) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deckBuiltinAction(action string, deck *deck.Deck) error {
|
func deckBuiltinAction(action string, deck *Deck) error {
|
||||||
switch action {
|
switch action {
|
||||||
case "brightness-up":
|
case "brightness-up":
|
||||||
current := deck.Brightness()
|
current := deck.Brightness()
|
||||||
@@ -286,3 +312,13 @@ func execYDOTool(keys string) error {
|
|||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) PageNames() []string {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
names := make([]string, 0, len(pm.pages))
|
||||||
|
for name := range pm.pages {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
@@ -265,7 +264,6 @@ func (d *niriDetector) getWindow(ctx context.Context) Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type gnomeDetector struct{ basePoll }
|
type gnomeDetector struct{ basePoll }
|
||||||
|
|
||||||
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||||
d.pollFn = d.getWindow
|
d.pollFn = d.getWindow
|
||||||
ctx, d.cancel = context.WithCancel(ctx)
|
ctx, d.cancel = context.WithCancel(ctx)
|
||||||
@@ -308,7 +306,6 @@ func parseGnomeEval(out string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kdeDetector struct{ basePoll }
|
type kdeDetector struct{ basePoll }
|
||||||
|
|
||||||
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||||
d.pollFn = d.getWindow
|
d.pollFn = d.getWindow
|
||||||
ctx, d.cancel = context.WithCancel(ctx)
|
ctx, d.cancel = context.WithCancel(ctx)
|
||||||
@@ -325,7 +322,6 @@ func (d *kdeDetector) getWindow(ctx context.Context) Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type x11Detector struct{ basePoll }
|
type x11Detector struct{ basePoll }
|
||||||
|
|
||||||
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
|
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
|
||||||
d.pollFn = d.getWindow
|
d.pollFn = d.getWindow
|
||||||
ctx, d.cancel = context.WithCancel(ctx)
|
ctx, d.cancel = context.WithCancel(ctx)
|
||||||
@@ -342,7 +338,6 @@ func (d *x11Detector) getWindow(ctx context.Context) Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type portalDetector struct{ basePoll }
|
type portalDetector struct{ basePoll }
|
||||||
|
|
||||||
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
|
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
|
||||||
d.pollFn = d.getWindow
|
d.pollFn = d.getWindow
|
||||||
ctx, d.cancel = context.WithCancel(ctx)
|
ctx, d.cancel = context.WithCancel(ctx)
|
||||||
@@ -362,7 +357,6 @@ type AutoSwitchManager struct {
|
|||||||
lastManualPage string
|
lastManualPage string
|
||||||
autoPage string
|
autoPage string
|
||||||
autoStay bool
|
autoStay bool
|
||||||
paused atomic.Bool // disables auto-switch when device is disconnected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type compiledRule struct {
|
type compiledRule struct {
|
||||||
@@ -416,25 +410,6 @@ func (m *AutoSwitchManager) NotifyManualPage(name string) {
|
|||||||
slog.Debug("auto-switch: manual page set", "page", name)
|
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) {
|
func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type cbdtIndex struct {
|
type cbdtIndex struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
imgs []image.Image
|
imgs []image.Image
|
||||||
scale int
|
scale int
|
||||||
}
|
}
|
||||||
|
|
||||||
var emojiCBDT cbdtIndex
|
var emojiCBDT cbdtIndex
|
||||||
@@ -40,19 +40,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"icon": "fa:gamepad",
|
|
||||||
"label": "Games",
|
|
||||||
"font_size": 11,
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"trigger": "tap",
|
|
||||||
"type": "page",
|
|
||||||
"page": "games"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"index": 7,
|
"index": 7,
|
||||||
"icon": "fa:chevron-right",
|
"icon": "fa:chevron-right",
|
||||||
@@ -71,23 +58,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "games",
|
|
||||||
"icon": "fa:gamepad",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"icon": "fa:arrow-left",
|
|
||||||
"actions": [
|
|
||||||
{ "trigger": "tap", "type": "page", "page": "main" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dynamic_keys": {
|
|
||||||
"script": "scripts/lutris-keys",
|
|
||||||
"interval": "120s"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auto_switch": [],
|
"auto_switch": [],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -8,9 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
"streamdeck-lets-go/internal/render"
|
|
||||||
"streamdeck-lets-go/internal/web"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type RunOptions struct {
|
type RunOptions struct {
|
||||||
@@ -18,11 +15,10 @@ type RunOptions struct {
|
|||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
HTTPEnabled bool
|
HTTPEnabled bool
|
||||||
NoDeck bool
|
NoDeck bool
|
||||||
StartPage string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||||
web := web.NewWebServer(cfg, opts.ConfigPath)
|
web := NewWebServer(cfg, opts.ConfigPath)
|
||||||
|
|
||||||
if !opts.NoDeck || opts.HTTPEnabled {
|
if !opts.NoDeck || opts.HTTPEnabled {
|
||||||
checkKeyboardTool()
|
checkKeyboardTool()
|
||||||
@@ -41,11 +37,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var decks []*deck.Deck
|
var decks []*Deck
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for {
|
for {
|
||||||
decks, err = deck.OpenAllDecks()
|
decks, err = OpenAllDecks()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -57,26 +53,16 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pageMgrs := make([]*render.PageManager, len(decks))
|
pageMgrs := make([]*PageManager, len(decks))
|
||||||
for i, d := range decks {
|
for i, d := range decks {
|
||||||
pageMgrs[i] = render.NewPageManager(d)
|
pageMgrs[i] = NewPageManager(d)
|
||||||
pageMgrs[i].DefaultFont = cfg.Font
|
pageMgrs[i].defaultFont = cfg.Font
|
||||||
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
|
pageMgrs[i].showLabelBackground = cfg.ShowLabelBackground
|
||||||
pageMgrs[i].LoadPages(cfg.Pages)
|
pageMgrs[i].LoadPages(cfg.Pages)
|
||||||
}
|
}
|
||||||
primaryPM := pageMgrs[0]
|
primaryPM := pageMgrs[0]
|
||||||
primaryDeck := decks[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() {
|
defer func() {
|
||||||
for _, d := range decks {
|
for _, d := range decks {
|
||||||
d.Close()
|
d.Close()
|
||||||
@@ -92,10 +78,10 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
web.SetExtraPageManagers(pageMgrs[1:])
|
web.SetExtraPageManagers(pageMgrs[1:])
|
||||||
|
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
if err := pm.ActivatePage(startPage); err != nil {
|
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||||
slog.Warn("activate start page", "error", err)
|
slog.Warn("activate default page", "error", err)
|
||||||
}
|
}
|
||||||
pm.StartPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
var windowCh <-chan Window
|
var windowCh <-chan Window
|
||||||
@@ -112,7 +98,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
windowCh, _ = detector.Start(ctx)
|
windowCh, _ = detector.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage)
|
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
|
||||||
|
|
||||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||||
|
|
||||||
@@ -130,8 +116,8 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
asm.NotifyManualPage(a.Page)
|
asm.NotifyManualPage(a.Page)
|
||||||
}
|
}
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
pm.StopPeriodicKeys()
|
pm.stopPeriodicKeys()
|
||||||
pm.StartPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
web.BroadcastPageChange(newPage)
|
web.BroadcastPageChange(newPage)
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +125,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
reconnectTicker := time.NewTicker(2 * time.Second)
|
reconnectTicker := time.NewTicker(5 * time.Second)
|
||||||
defer reconnectTicker.Stop()
|
defer reconnectTicker.Stop()
|
||||||
|
|
||||||
ssTicker := time.NewTicker(5 * time.Second)
|
ssTicker := time.NewTicker(5 * time.Second)
|
||||||
@@ -157,34 +143,22 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
case evt, ok := <-primaryDeck.Events():
|
case evt, ok := <-primaryDeck.Events():
|
||||||
if !ok {
|
if !ok {
|
||||||
slog.Warn("deck event channel closed, attempting reconnect...")
|
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()
|
primaryDeck.Close()
|
||||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
|
||||||
if newDeck == nil {
|
if newDeck == nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all references to the new device and page manager.
|
|
||||||
pageMgrs[0] = primaryPM
|
|
||||||
decks[0] = newDeck
|
decks[0] = newDeck
|
||||||
primaryDeck = newDeck
|
primaryDeck = newDeck
|
||||||
web.SetDecks(decks)
|
web.SetDecks(decks)
|
||||||
web.SetPageManager(primaryPM)
|
|
||||||
|
|
||||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||||
asm.NotifyManualPage(startPage)
|
|
||||||
asm.Resume()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
wasSsActive := ssCtrl.IsActive()
|
wasSsActive := ssCtrl.IsActive()
|
||||||
ssCtrl.NotifyInput()
|
ssCtrl.NotifyInput()
|
||||||
|
|
||||||
if evt.Kind == deck.EventKeyPressed {
|
if evt.Kind == EventKeyPressed {
|
||||||
if wasSsActive {
|
if wasSsActive {
|
||||||
ssCtrl.Deactivate(primaryDeck)
|
ssCtrl.Deactivate(primaryDeck)
|
||||||
|
|
||||||
@@ -210,7 +184,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
if page == nil {
|
if page == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, k := range primaryPM.ActiveKeys() {
|
for _, k := range page.Keys {
|
||||||
if k.Index == evt.Index {
|
if k.Index == evt.Index {
|
||||||
if len(k.Actions) > 0 {
|
if len(k.Actions) > 0 {
|
||||||
ge.HandleEvent(evt, k.Actions)
|
ge.HandleEvent(evt, k.Actions)
|
||||||
@@ -220,20 +194,13 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case win := <-windowCh:
|
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 {
|
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
if err := pm.ActivatePage(page); err != nil {
|
if err := pm.ActivatePage(page); err != nil {
|
||||||
slog.Warn("auto-switch: activate page", "error", err)
|
slog.Warn("auto-switch: activate page", "error", err)
|
||||||
}
|
}
|
||||||
pm.StopPeriodicKeys()
|
pm.stopPeriodicKeys()
|
||||||
pm.StartPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
web.BroadcastPageChange(page)
|
web.BroadcastPageChange(page)
|
||||||
}
|
}
|
||||||
@@ -259,14 +226,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
for _, d := range decks {
|
for _, d := range decks {
|
||||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||||
}
|
}
|
||||||
var activePages []string
|
var activePages []string
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
activePages = append(activePages, pm.ActivePageName())
|
activePages = append(activePages, pm.ActivePageName())
|
||||||
pm.StopPeriodicKeys()
|
pm.stopPeriodicKeys()
|
||||||
pm.DefaultFont = cfg.Font
|
pm.defaultFont = cfg.Font
|
||||||
pm.ShowLabelBackground = cfg.ShowLabelBackground
|
pm.showLabelBackground = cfg.ShowLabelBackground
|
||||||
pm.LoadPages(cfg.Pages)
|
pm.LoadPages(cfg.Pages)
|
||||||
}
|
}
|
||||||
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
|
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
|
||||||
asm.Reload(cfg.AutoSwitch)
|
asm.Reload(cfg.AutoSwitch)
|
||||||
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||||
@@ -286,7 +253,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
if err := pm.ActivatePage(page); err != nil {
|
if err := pm.ActivatePage(page); err != nil {
|
||||||
slog.Warn("reload: activate page", "error", err)
|
slog.Warn("reload: activate page", "error", err)
|
||||||
}
|
}
|
||||||
pm.StartPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lastConfigMod.IsZero() {
|
if lastConfigMod.IsZero() {
|
||||||
@@ -296,27 +263,15 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
case <-reconnectTicker.C:
|
case <-reconnectTicker.C:
|
||||||
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
|
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
|
||||||
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
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()
|
primaryDeck.Close()
|
||||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
|
||||||
if newDeck == nil {
|
if newDeck == nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all references to the new device and page manager.
|
|
||||||
pageMgrs[0] = primaryPM
|
|
||||||
decks[0] = newDeck
|
decks[0] = newDeck
|
||||||
primaryDeck = newDeck
|
primaryDeck = newDeck
|
||||||
web.SetDecks(decks)
|
web.SetDecks(decks)
|
||||||
web.SetPageManager(primaryPM)
|
|
||||||
|
|
||||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||||
asm.NotifyManualPage(startPage)
|
|
||||||
asm.Resume()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-ssTicker.C:
|
case <-ssTicker.C:
|
||||||
@@ -367,32 +322,23 @@ func deviceBrightness(cfg *config.Config, serial string) int {
|
|||||||
return 75
|
return 75
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck {
|
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck {
|
||||||
for {
|
for {
|
||||||
newDeck, err := deck.OpenDeck("")
|
newDeck, err := OpenDeck("")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
*pm = render.NewPageManager(newDeck)
|
*pm = NewPageManager(newDeck)
|
||||||
(*pm).DefaultFont = cfg.Font
|
(*pm).defaultFont = cfg.Font
|
||||||
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
|
(*pm).showLabelBackground = cfg.ShowLabelBackground
|
||||||
(*pm).LoadPages(cfg.Pages)
|
(*pm).LoadPages(cfg.Pages)
|
||||||
(*pm).ActivatePage(startPage)
|
(*pm).ActivatePage(cfg.DefaultPage)
|
||||||
(*pm).StartPeriodicKeys()
|
(*pm).startPeriodicKeys()
|
||||||
|
asm.NotifyManualPage(cfg.DefaultPage)
|
||||||
return newDeck
|
return newDeck
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageExists reports whether pages contains a page with the given name.
|
|
||||||
func pageExists(pages []config.PageConfig, name string) bool {
|
|
||||||
for _, p := range pages {
|
|
||||||
if p.Name == name {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package deck
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,28 +22,28 @@ type DeviceInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeckConfig struct {
|
type DeckConfig struct {
|
||||||
PID uint16
|
PID uint16
|
||||||
Name string
|
Name string
|
||||||
KeysX int
|
KeysX int
|
||||||
KeysY int
|
KeysY int
|
||||||
KeySize int
|
KeySize int
|
||||||
ImageFmt string
|
ImageFmt string
|
||||||
Rotate bool
|
Rotate bool
|
||||||
Convert bool
|
Convert bool
|
||||||
HasDials bool
|
HasDials bool
|
||||||
HasTouch bool
|
HasTouch bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var KnownDecks = []DeckConfig{
|
var knownDecks = []DeckConfig{
|
||||||
{PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true},
|
{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: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
||||||
{PID: 0x63, Name: "OriginalV2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
|
{PID: 0x63, Name: "OriginalV2",KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
|
||||||
{PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
{PID: 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"},
|
{PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func findConfig(pid uint16) (DeckConfig, bool) {
|
func findConfig(pid uint16) (DeckConfig, bool) {
|
||||||
for _, d := range KnownDecks {
|
for _, d := range knownDecks {
|
||||||
if d.PID == pid {
|
if d.PID == pid {
|
||||||
return d, true
|
return d, true
|
||||||
}
|
}
|
||||||
@@ -161,12 +161,12 @@ func (d *Deck) Close() {
|
|||||||
d.sd.Close()
|
d.sd.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deck) Serial() string { return d.serial }
|
func (d *Deck) Serial() string { return d.serial }
|
||||||
func (d *Deck) Events() <-chan Event { return d.events }
|
func (d *Deck) Events() <-chan Event { return d.events }
|
||||||
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
|
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
|
||||||
func (d *Deck) KeySize() int { return d.cfg.KeySize }
|
func (d *Deck) KeySize() int { return d.cfg.KeySize }
|
||||||
func (d *Deck) Config() DeckConfig { return d.cfg }
|
func (d *Deck) Config() DeckConfig { return d.cfg }
|
||||||
func (d *Deck) Model() string { return d.cfg.Name }
|
func (d *Deck) Model() string { return d.cfg.Name }
|
||||||
|
|
||||||
type DeckInfo struct {
|
type DeckInfo struct {
|
||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
var emojiShortcodes = map[string]rune{
|
||||||
|
// media
|
||||||
|
"play_pause": 0x25B6,
|
||||||
|
"stop": 0x23F9,
|
||||||
|
"record": 0x23FA,
|
||||||
|
"eject": 0x23CF,
|
||||||
|
"track_previous": 0x23EE,
|
||||||
|
"track_next": 0x23ED,
|
||||||
|
"fast_forward": 0x23E9,
|
||||||
|
"rewind": 0x23EA,
|
||||||
|
"shuffle": 0x1F500,
|
||||||
|
"repeat": 0x1F501,
|
||||||
|
"repeat_one": 0x1F502,
|
||||||
|
|
||||||
|
// volume
|
||||||
|
"speaker": 0x1F50A,
|
||||||
|
"mute": 0x1F507,
|
||||||
|
"sound": 0x1F509,
|
||||||
|
|
||||||
|
// navigation
|
||||||
|
"arrow_up": 0x2B06,
|
||||||
|
"arrow_down": 0x2B07,
|
||||||
|
"arrow_left": 0x2B05,
|
||||||
|
"arrow_right": 0x27A1,
|
||||||
|
"arrows_clockwise": 0x1F503,
|
||||||
|
"arrows_counterclockwise": 0x1F504,
|
||||||
|
|
||||||
|
// status
|
||||||
|
"check": 0x2705,
|
||||||
|
"heavy_check_mark": 0x2714,
|
||||||
|
"x": 0x274C,
|
||||||
|
"heavy_multiplication_x": 0x2716,
|
||||||
|
"warning": 0x26A0,
|
||||||
|
"information_source": 0x2139,
|
||||||
|
"question": 0x2753,
|
||||||
|
"exclamation": 0x2757,
|
||||||
|
"white_check_mark": 0x2705,
|
||||||
|
"heavy_plus_sign": 0x2795,
|
||||||
|
"heavy_minus_sign": 0x2796,
|
||||||
|
"heavy_division_sign": 0x2797,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
"gear": 0x2699,
|
||||||
|
"hammer": 0x1F528,
|
||||||
|
"wrench": 0x1F527,
|
||||||
|
"key": 0x1F511,
|
||||||
|
"lock": 0x1F512,
|
||||||
|
"unlocked": 0x1F513,
|
||||||
|
"magnifying_glass": 0x1F50D,
|
||||||
|
"home": 0x1F3E0,
|
||||||
|
"bookmark": 0x1F516,
|
||||||
|
"bell": 0x1F514,
|
||||||
|
"clock": 0x1F550,
|
||||||
|
"alarm_clock": 0x23F0,
|
||||||
|
"hourglass": 0x231B,
|
||||||
|
"calendar": 0x1F4C5,
|
||||||
|
"envelope": 0x2709,
|
||||||
|
"camera": 0x1F4F7,
|
||||||
|
"video_camera": 0x1F4F9,
|
||||||
|
"microphone": 0x1F3A4,
|
||||||
|
"telephone": 0x260E,
|
||||||
|
"phone": 0x1F4DE,
|
||||||
|
"computer": 0x1F4BB,
|
||||||
|
"laptop": 0x1F4BB,
|
||||||
|
"folder": 0x1F4C1,
|
||||||
|
"open_file_folder": 0x1F4C2,
|
||||||
|
"clipboard": 0x1F4CB,
|
||||||
|
"memo": 0x1F4DD,
|
||||||
|
"pencil": 0x270F,
|
||||||
|
"scissors": 0x2702,
|
||||||
|
"link": 0x1F517,
|
||||||
|
"paperclip": 0x1F4CE,
|
||||||
|
"pushpin": 0x1F4CC,
|
||||||
|
"trash": 0x1F5D1,
|
||||||
|
"star": 0x2B50,
|
||||||
|
"trophy": 0x1F3C6,
|
||||||
|
"medal": 0x1F3C5,
|
||||||
|
"target": 0x1F3AF,
|
||||||
|
"dart": 0x1F3AF,
|
||||||
|
|
||||||
|
// objects
|
||||||
|
"lightbulb": 0x1F4A1,
|
||||||
|
"bulb": 0x1F4A1,
|
||||||
|
"battery": 0x1F50B,
|
||||||
|
"electric_plug": 0x1F50C,
|
||||||
|
"rocket": 0x1F680,
|
||||||
|
"airplane": 0x2708,
|
||||||
|
"car": 0x1F697,
|
||||||
|
"bicycle": 0x1F6B2,
|
||||||
|
"headphones": 0x1F3A7,
|
||||||
|
"gamepad": 0x1F3AE,
|
||||||
|
"joystick": 0x1F579,
|
||||||
|
"musical_note": 0x1F3B5,
|
||||||
|
"notes": 0x1F3B6,
|
||||||
|
"printer": 0x1F5A8,
|
||||||
|
"keyboard": 0x2328,
|
||||||
|
|
||||||
|
// weather
|
||||||
|
"sun": 0x2600,
|
||||||
|
"sunny": 0x2600,
|
||||||
|
"moon": 0x1F319,
|
||||||
|
"cloud": 0x2601,
|
||||||
|
"rainbow": 0x1F308,
|
||||||
|
"fire": 0x1F525,
|
||||||
|
"flame": 0x1F525,
|
||||||
|
"zap": 0x26A1,
|
||||||
|
"lightning": 0x26A1,
|
||||||
|
"snowflake": 0x2744,
|
||||||
|
"umbrella": 0x2602,
|
||||||
|
|
||||||
|
// hearts
|
||||||
|
"heart": 0x2764,
|
||||||
|
"yellow_heart": 0x1F49B,
|
||||||
|
"green_heart": 0x1F49A,
|
||||||
|
"blue_heart": 0x1F499,
|
||||||
|
"purple_heart": 0x1F49C,
|
||||||
|
"black_heart": 0x1F5A4,
|
||||||
|
"broken_heart": 0x1F494,
|
||||||
|
"two_hearts": 0x1F495,
|
||||||
|
"sparkling_heart": 0x1F496,
|
||||||
|
"heartpulse": 0x1F497,
|
||||||
|
"heart_beat": 0x1F493,
|
||||||
|
"revolving_hearts": 0x1F49E,
|
||||||
|
"cupid": 0x1F498,
|
||||||
|
"gift_heart": 0x1F49D,
|
||||||
|
|
||||||
|
// faces
|
||||||
|
"smile": 0x1F600,
|
||||||
|
"smiley": 0x1F603,
|
||||||
|
"grinning": 0x1F604,
|
||||||
|
"blush": 0x1F60A,
|
||||||
|
"wink": 0x1F609,
|
||||||
|
"heart_eyes": 0x1F60D,
|
||||||
|
"kissing_heart": 0x1F618,
|
||||||
|
"kissing": 0x1F617,
|
||||||
|
"smirk": 0x1F60F,
|
||||||
|
"stuck_out_tongue": 0x1F61B,
|
||||||
|
"stuck_out_tongue_winking_eye": 0x1F61C,
|
||||||
|
"sunglasses": 0x1F60E,
|
||||||
|
"innocent": 0x1F607,
|
||||||
|
"neutral_face": 0x1F610,
|
||||||
|
"expressionless": 0x1F611,
|
||||||
|
"thinking": 0x1F914,
|
||||||
|
"confused": 0x1F615,
|
||||||
|
"worried": 0x1F61F,
|
||||||
|
"frown": 0x1F641,
|
||||||
|
"persevere": 0x1F623,
|
||||||
|
"tired": 0x1F62B,
|
||||||
|
"weary": 0x1F629,
|
||||||
|
"cry": 0x1F622,
|
||||||
|
"sob": 0x1F62D,
|
||||||
|
"sweat_smile": 0x1F605,
|
||||||
|
"joy": 0x1F602,
|
||||||
|
"relaxed": 0x263A,
|
||||||
|
"angry": 0x1F620,
|
||||||
|
"rage": 0x1F621,
|
||||||
|
"skull": 0x1F480,
|
||||||
|
"ghost": 0x1F47B,
|
||||||
|
"robot": 0x1F916,
|
||||||
|
"sleeping": 0x1F634,
|
||||||
|
"sleep": 0x1F634,
|
||||||
|
"zzz": 0x1F4A4,
|
||||||
|
"dizzy": 0x1F4AB,
|
||||||
|
"boom": 0x1F4A5,
|
||||||
|
"collision": 0x1F4A5,
|
||||||
|
"sweat_drops": 0x1F4A6,
|
||||||
|
"dash": 0x1F4A8,
|
||||||
|
"alien": 0x1F47D,
|
||||||
|
"poop": 0x1F4A9,
|
||||||
|
|
||||||
|
// hands & gestures
|
||||||
|
"thumbsup": 0x1F44D,
|
||||||
|
"thumbsdown": 0x1F44E,
|
||||||
|
"ok_hand": 0x1F44C,
|
||||||
|
"wave": 0x1F44B,
|
||||||
|
"clap": 0x1F44F,
|
||||||
|
"open_hands": 0x1F450,
|
||||||
|
"raised_hands": 0x1F64C,
|
||||||
|
"pray": 0x1F64F,
|
||||||
|
"muscle": 0x1F4AA,
|
||||||
|
"point_up": 0x261D,
|
||||||
|
"point_down": 0x1F447,
|
||||||
|
"point_left": 0x1F448,
|
||||||
|
"point_right": 0x1F449,
|
||||||
|
"fist": 0x270A,
|
||||||
|
"raised_hand": 0x270B,
|
||||||
|
"v": 0x270C,
|
||||||
|
"victory": 0x270C,
|
||||||
|
"crossed_fingers": 0x1F91E,
|
||||||
|
"writing_hand": 0x270D,
|
||||||
|
"call_me": 0x1F919,
|
||||||
|
"hand": 0x270B,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
emojiColorFontOnce sync.Once
|
||||||
|
emojiColorFontData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadColorEmojiFont() []byte {
|
||||||
|
emojiColorFontOnce.Do(func() {
|
||||||
|
emojiColorFontData = fcRead("emoji")
|
||||||
|
})
|
||||||
|
return emojiColorFontData
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -138,3 +138,4 @@ func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.I
|
|||||||
func isFAIconRef(path string) bool {
|
func isFAIconRef(path string) bool {
|
||||||
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
|
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
@@ -14,7 +14,7 @@ var faFonts embed.FS
|
|||||||
type faStyle int
|
type faStyle int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
faSolid faStyle = iota
|
faSolid faStyle = iota
|
||||||
faRegular
|
faRegular
|
||||||
faBrands
|
faBrands
|
||||||
)
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionCallback func(idx int, a *config.Action)
|
type ActionCallback func(idx int, a *config.Action)
|
||||||
@@ -96,7 +95,7 @@ func findAction(actions []config.KeyAction, trigger string) *config.Action {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) {
|
func (ge *GestureEngine) HandleEvent(evt Event, actions []config.KeyAction) {
|
||||||
state := ge.getState(evt.Index)
|
state := ge.getState(evt.Index)
|
||||||
|
|
||||||
if len(actions) == 0 {
|
if len(actions) == 0 {
|
||||||
@@ -104,14 +103,14 @@ func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction)
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch evt.Kind {
|
switch evt.Kind {
|
||||||
case deck.EventKeyPressed:
|
case EventKeyPressed:
|
||||||
ge.handleKeyPress(state, evt, actions)
|
ge.handleKeyPress(state, evt, actions)
|
||||||
case deck.EventKeyReleased:
|
case EventKeyReleased:
|
||||||
ge.handleKeyRelease(state, evt, actions)
|
ge.handleKeyRelease(state, evt, actions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt Event, actions []config.KeyAction) {
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
state.cancelTimers()
|
state.cancelTimers()
|
||||||
state.holdActive = false
|
state.holdActive = false
|
||||||
@@ -131,7 +130,7 @@ func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event,
|
|||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt Event, actions []config.KeyAction) {
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
state.cancelTimers()
|
state.cancelTimers()
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
+19
-72
@@ -11,16 +11,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
LogLevel string `json:"log_level"`
|
LogLevel string `json:"log_level"`
|
||||||
DefaultPage string `json:"default_page"`
|
DefaultPage string `json:"default_page"`
|
||||||
Font string `json:"font,omitempty"`
|
Font string `json:"font,omitempty"`
|
||||||
ShowLabelBackground bool `json:"show_label_background"`
|
ShowLabelBackground bool `json:"show_label_background"`
|
||||||
Devices []DeviceConfig `json:"devices"`
|
Devices []DeviceConfig `json:"devices"`
|
||||||
Pages []PageConfig `json:"pages"`
|
Pages []PageConfig `json:"pages"`
|
||||||
AutoSwitch []SwitchRule `json:"auto_switch"`
|
AutoSwitch []SwitchRule `json:"auto_switch"`
|
||||||
Screensaver ScreensaverCfg `json:"screensaver"`
|
Screensaver ScreensaverCfg `json:"screensaver"`
|
||||||
Timing TimingConfig `json:"timing,omitempty"`
|
Timing TimingConfig `json:"timing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimingConfig struct {
|
type TimingConfig struct {
|
||||||
@@ -43,29 +43,20 @@ type PageConfig struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Keys []KeyConfig `json:"keys"`
|
Keys []KeyConfig `json:"keys"`
|
||||||
DynamicKeys *DynamicKeyGen `json:"dynamic_keys,omitempty"`
|
|
||||||
Background string `json:"background,omitempty"`
|
Background string `json:"background,omitempty"`
|
||||||
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
|
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DynamicKeyGen struct {
|
|
||||||
Command string `json:"command,omitempty"`
|
|
||||||
Script string `json:"script,omitempty"`
|
|
||||||
Interval string `json:"interval,omitempty"`
|
|
||||||
Timeout string `json:"timeout,omitempty"`
|
|
||||||
MaxKeys int `json:"max_keys,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KeyConfig struct {
|
type KeyConfig struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Background string `json:"background,omitempty"`
|
Background string `json:"background,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Font string `json:"font,omitempty"`
|
Font string `json:"font,omitempty"`
|
||||||
FontSize *float64 `json:"font_size,omitempty"`
|
FontSize *float64 `json:"font_size,omitempty"`
|
||||||
IconScale *float64 `json:"icon_scale,omitempty"`
|
IconScale *float64 `json:"icon_scale,omitempty"`
|
||||||
Actions []KeyAction `json:"actions,omitempty"`
|
Actions []KeyAction `json:"actions,omitempty"`
|
||||||
Display *DisplayCfg `json:"display,omitempty"`
|
Display *DisplayCfg `json:"display,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyAction struct {
|
type KeyAction struct {
|
||||||
@@ -151,21 +142,6 @@ func ConfigPath(path string) string {
|
|||||||
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
|
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) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
path = ConfigPath(path)
|
path = ConfigPath(path)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -317,35 +293,6 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.DynamicKeys != nil {
|
|
||||||
if p.DynamicKeys.Command == "" && p.DynamicKeys.Script == "" {
|
|
||||||
return fmt.Errorf("page %s: dynamic_keys requires command or script", p.Name)
|
|
||||||
}
|
|
||||||
if p.DynamicKeys.Command != "" && p.DynamicKeys.Script != "" {
|
|
||||||
return fmt.Errorf("page %s: dynamic_keys command and script are mutually exclusive", p.Name)
|
|
||||||
}
|
|
||||||
if p.DynamicKeys.Interval != "" {
|
|
||||||
interval, err := time.ParseDuration(p.DynamicKeys.Interval)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("page %s: invalid dynamic_keys interval %q: %w", p.Name, p.DynamicKeys.Interval, err)
|
|
||||||
}
|
|
||||||
if interval < time.Second {
|
|
||||||
return fmt.Errorf("page %s: dynamic_keys interval must be at least 1s", p.Name)
|
|
||||||
}
|
|
||||||
if interval > 24*time.Hour {
|
|
||||||
return fmt.Errorf("page %s: dynamic_keys interval must not exceed 24h", p.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.DynamicKeys.Timeout != "" {
|
|
||||||
if _, err := time.ParseDuration(p.DynamicKeys.Timeout); err != nil {
|
|
||||||
return fmt.Errorf("page %s: invalid dynamic_keys timeout %q: %w", p.Name, p.DynamicKeys.Timeout, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.DynamicKeys.MaxKeys < 0 {
|
|
||||||
return fmt.Errorf("page %s: dynamic_keys max_keys must be >= 0", p.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range c.Pages {
|
for _, p := range c.Pages {
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
package render
|
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
var emojiShortcodes = map[string]rune{
|
|
||||||
// media
|
|
||||||
"play_pause": 0x25B6,
|
|
||||||
"stop": 0x23F9,
|
|
||||||
"record": 0x23FA,
|
|
||||||
"eject": 0x23CF,
|
|
||||||
"track_previous": 0x23EE,
|
|
||||||
"track_next": 0x23ED,
|
|
||||||
"fast_forward": 0x23E9,
|
|
||||||
"rewind": 0x23EA,
|
|
||||||
"shuffle": 0x1F500,
|
|
||||||
"repeat": 0x1F501,
|
|
||||||
"repeat_one": 0x1F502,
|
|
||||||
|
|
||||||
// volume
|
|
||||||
"speaker": 0x1F50A,
|
|
||||||
"mute": 0x1F507,
|
|
||||||
"sound": 0x1F509,
|
|
||||||
|
|
||||||
// navigation
|
|
||||||
"arrow_up": 0x2B06,
|
|
||||||
"arrow_down": 0x2B07,
|
|
||||||
"arrow_left": 0x2B05,
|
|
||||||
"arrow_right": 0x27A1,
|
|
||||||
"arrows_clockwise": 0x1F503,
|
|
||||||
"arrows_counterclockwise": 0x1F504,
|
|
||||||
|
|
||||||
// status
|
|
||||||
"check": 0x2705,
|
|
||||||
"heavy_check_mark": 0x2714,
|
|
||||||
"x": 0x274C,
|
|
||||||
"heavy_multiplication_x": 0x2716,
|
|
||||||
"warning": 0x26A0,
|
|
||||||
"information_source": 0x2139,
|
|
||||||
"question": 0x2753,
|
|
||||||
"exclamation": 0x2757,
|
|
||||||
"white_check_mark": 0x2705,
|
|
||||||
"heavy_plus_sign": 0x2795,
|
|
||||||
"heavy_minus_sign": 0x2796,
|
|
||||||
"heavy_division_sign": 0x2797,
|
|
||||||
|
|
||||||
// actions
|
|
||||||
"gear": 0x2699,
|
|
||||||
"hammer": 0x1F528,
|
|
||||||
"wrench": 0x1F527,
|
|
||||||
"key": 0x1F511,
|
|
||||||
"lock": 0x1F512,
|
|
||||||
"unlocked": 0x1F513,
|
|
||||||
"magnifying_glass": 0x1F50D,
|
|
||||||
"home": 0x1F3E0,
|
|
||||||
"bookmark": 0x1F516,
|
|
||||||
"bell": 0x1F514,
|
|
||||||
"clock": 0x1F550,
|
|
||||||
"alarm_clock": 0x23F0,
|
|
||||||
"hourglass": 0x231B,
|
|
||||||
"calendar": 0x1F4C5,
|
|
||||||
"envelope": 0x2709,
|
|
||||||
"camera": 0x1F4F7,
|
|
||||||
"video_camera": 0x1F4F9,
|
|
||||||
"microphone": 0x1F3A4,
|
|
||||||
"telephone": 0x260E,
|
|
||||||
"phone": 0x1F4DE,
|
|
||||||
"computer": 0x1F4BB,
|
|
||||||
"laptop": 0x1F4BB,
|
|
||||||
"folder": 0x1F4C1,
|
|
||||||
"open_file_folder": 0x1F4C2,
|
|
||||||
"clipboard": 0x1F4CB,
|
|
||||||
"memo": 0x1F4DD,
|
|
||||||
"pencil": 0x270F,
|
|
||||||
"scissors": 0x2702,
|
|
||||||
"link": 0x1F517,
|
|
||||||
"paperclip": 0x1F4CE,
|
|
||||||
"pushpin": 0x1F4CC,
|
|
||||||
"trash": 0x1F5D1,
|
|
||||||
"star": 0x2B50,
|
|
||||||
"trophy": 0x1F3C6,
|
|
||||||
"medal": 0x1F3C5,
|
|
||||||
"target": 0x1F3AF,
|
|
||||||
"dart": 0x1F3AF,
|
|
||||||
|
|
||||||
// objects
|
|
||||||
"lightbulb": 0x1F4A1,
|
|
||||||
"bulb": 0x1F4A1,
|
|
||||||
"battery": 0x1F50B,
|
|
||||||
"electric_plug": 0x1F50C,
|
|
||||||
"rocket": 0x1F680,
|
|
||||||
"airplane": 0x2708,
|
|
||||||
"car": 0x1F697,
|
|
||||||
"bicycle": 0x1F6B2,
|
|
||||||
"headphones": 0x1F3A7,
|
|
||||||
"gamepad": 0x1F3AE,
|
|
||||||
"joystick": 0x1F579,
|
|
||||||
"musical_note": 0x1F3B5,
|
|
||||||
"notes": 0x1F3B6,
|
|
||||||
"printer": 0x1F5A8,
|
|
||||||
"keyboard": 0x2328,
|
|
||||||
|
|
||||||
// weather
|
|
||||||
"sun": 0x2600,
|
|
||||||
"sunny": 0x2600,
|
|
||||||
"moon": 0x1F319,
|
|
||||||
"cloud": 0x2601,
|
|
||||||
"rainbow": 0x1F308,
|
|
||||||
"fire": 0x1F525,
|
|
||||||
"flame": 0x1F525,
|
|
||||||
"zap": 0x26A1,
|
|
||||||
"lightning": 0x26A1,
|
|
||||||
"snowflake": 0x2744,
|
|
||||||
"umbrella": 0x2602,
|
|
||||||
|
|
||||||
// hearts
|
|
||||||
"heart": 0x2764,
|
|
||||||
"yellow_heart": 0x1F49B,
|
|
||||||
"green_heart": 0x1F49A,
|
|
||||||
"blue_heart": 0x1F499,
|
|
||||||
"purple_heart": 0x1F49C,
|
|
||||||
"black_heart": 0x1F5A4,
|
|
||||||
"broken_heart": 0x1F494,
|
|
||||||
"two_hearts": 0x1F495,
|
|
||||||
"sparkling_heart": 0x1F496,
|
|
||||||
"heartpulse": 0x1F497,
|
|
||||||
"heart_beat": 0x1F493,
|
|
||||||
"revolving_hearts": 0x1F49E,
|
|
||||||
"cupid": 0x1F498,
|
|
||||||
"gift_heart": 0x1F49D,
|
|
||||||
|
|
||||||
// faces
|
|
||||||
"smile": 0x1F600,
|
|
||||||
"smiley": 0x1F603,
|
|
||||||
"grinning": 0x1F604,
|
|
||||||
"blush": 0x1F60A,
|
|
||||||
"wink": 0x1F609,
|
|
||||||
"heart_eyes": 0x1F60D,
|
|
||||||
"kissing_heart": 0x1F618,
|
|
||||||
"kissing": 0x1F617,
|
|
||||||
"smirk": 0x1F60F,
|
|
||||||
"stuck_out_tongue": 0x1F61B,
|
|
||||||
"stuck_out_tongue_winking_eye": 0x1F61C,
|
|
||||||
"sunglasses": 0x1F60E,
|
|
||||||
"innocent": 0x1F607,
|
|
||||||
"neutral_face": 0x1F610,
|
|
||||||
"expressionless": 0x1F611,
|
|
||||||
"thinking": 0x1F914,
|
|
||||||
"confused": 0x1F615,
|
|
||||||
"worried": 0x1F61F,
|
|
||||||
"frown": 0x1F641,
|
|
||||||
"persevere": 0x1F623,
|
|
||||||
"tired": 0x1F62B,
|
|
||||||
"weary": 0x1F629,
|
|
||||||
"cry": 0x1F622,
|
|
||||||
"sob": 0x1F62D,
|
|
||||||
"sweat_smile": 0x1F605,
|
|
||||||
"joy": 0x1F602,
|
|
||||||
"relaxed": 0x263A,
|
|
||||||
"angry": 0x1F620,
|
|
||||||
"rage": 0x1F621,
|
|
||||||
"skull": 0x1F480,
|
|
||||||
"ghost": 0x1F47B,
|
|
||||||
"robot": 0x1F916,
|
|
||||||
"sleeping": 0x1F634,
|
|
||||||
"sleep": 0x1F634,
|
|
||||||
"zzz": 0x1F4A4,
|
|
||||||
"dizzy": 0x1F4AB,
|
|
||||||
"boom": 0x1F4A5,
|
|
||||||
"collision": 0x1F4A5,
|
|
||||||
"sweat_drops": 0x1F4A6,
|
|
||||||
"dash": 0x1F4A8,
|
|
||||||
"alien": 0x1F47D,
|
|
||||||
"poop": 0x1F4A9,
|
|
||||||
|
|
||||||
// hands & gestures
|
|
||||||
"thumbsup": 0x1F44D,
|
|
||||||
"thumbsdown": 0x1F44E,
|
|
||||||
"ok_hand": 0x1F44C,
|
|
||||||
"wave": 0x1F44B,
|
|
||||||
"clap": 0x1F44F,
|
|
||||||
"open_hands": 0x1F450,
|
|
||||||
"raised_hands": 0x1F64C,
|
|
||||||
"pray": 0x1F64F,
|
|
||||||
"muscle": 0x1F4AA,
|
|
||||||
"point_up": 0x261D,
|
|
||||||
"point_down": 0x1F447,
|
|
||||||
"point_left": 0x1F448,
|
|
||||||
"point_right": 0x1F449,
|
|
||||||
"fist": 0x270A,
|
|
||||||
"raised_hand": 0x270B,
|
|
||||||
"v": 0x270C,
|
|
||||||
"victory": 0x270C,
|
|
||||||
"crossed_fingers": 0x1F91E,
|
|
||||||
"writing_hand": 0x270D,
|
|
||||||
"call_me": 0x1F919,
|
|
||||||
"hand": 0x270B,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
emojiColorFontOnce sync.Once
|
|
||||||
emojiColorFontData []byte
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadColorEmojiFont() []byte {
|
|
||||||
emojiColorFontOnce.Do(func() {
|
|
||||||
emojiColorFontData = fcRead("emoji")
|
|
||||||
})
|
|
||||||
return emojiColorFontData
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
@@ -18,8 +14,6 @@ import (
|
|||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/daemon"
|
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -28,16 +22,15 @@ func main() {
|
|||||||
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
|
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
|
||||||
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
|
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
|
||||||
httpAddr := fs.String("addr", ":9090", "web UI listen address")
|
httpAddr := fs.String("addr", ":9090", "web UI listen address")
|
||||||
startPage := fs.String("page", "", "page to activate on startup (default: config's default_page)")
|
|
||||||
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
|
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
|
||||||
verbose := fs.Bool("v", false, "verbose output")
|
verbose := fs.Bool("v", false, "verbose output")
|
||||||
|
|
||||||
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-help" || os.Args[1] == "-h" {
|
if len(os.Args) < 2 {
|
||||||
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
|
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
|
||||||
fmt.Fprintf(os.Stderr, "Commands:\n")
|
fmt.Fprintf(os.Stderr, "Commands:\n")
|
||||||
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
|
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
|
||||||
fmt.Fprintf(os.Stderr, " switch switch running daemon to a page (e.g. 'switch games')\n")
|
fmt.Fprintf(os.Stderr, " serve run web UI only (no deck hardware)\n")
|
||||||
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
|
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
|
||||||
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -49,26 +42,13 @@ func main() {
|
|||||||
switch cmd {
|
switch cmd {
|
||||||
case "daemon":
|
case "daemon":
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
case "switch":
|
case "serve":
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
if fs.NArg() == 0 {
|
*noDeck = true
|
||||||
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go switch <page>\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := switchToPage(*httpAddr, fs.Arg(0)); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("switched to page %q\n", fs.Arg(0))
|
|
||||||
return
|
|
||||||
case "discover":
|
case "discover":
|
||||||
discover()
|
discover()
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
if cmd == "serve" {
|
|
||||||
fmt.Fprintf(os.Stderr, "streamdeck-lets-go: 'serve' removed; use 'daemon --no-deck' instead\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fs.Parse(os.Args[1:])
|
fs.Parse(os.Args[1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +66,11 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
httpEnabled := true
|
httpEnabled := true
|
||||||
if err := daemon.Run(ctx, cfg, daemon.RunOptions{
|
if err := Run(ctx, cfg, RunOptions{
|
||||||
ConfigPath: *configPath,
|
ConfigPath: *configPath,
|
||||||
HTTPAddr: *httpAddr,
|
HTTPAddr: *httpAddr,
|
||||||
HTTPEnabled: httpEnabled,
|
HTTPEnabled: httpEnabled,
|
||||||
NoDeck: *noDeck,
|
NoDeck: *noDeck,
|
||||||
StartPage: *startPage,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("run", "error", err)
|
slog.Error("run", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -99,7 +78,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func discover() {
|
func discover() {
|
||||||
devices, err := deck.EnumerateDevices()
|
devices, err := EnumerateDevices()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("discover", "error", err)
|
slog.Error("discover", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -113,25 +92,3 @@ func discover() {
|
|||||||
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
|
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchToPage tells the running daemon (at httpAddr) to activate the given page
|
|
||||||
// by POSTing to its /api/activate-page endpoint.
|
|
||||||
func switchToPage(httpAddr, page string) error {
|
|
||||||
host := httpAddr
|
|
||||||
if strings.HasPrefix(host, ":") {
|
|
||||||
host = "127.0.0.1" + host
|
|
||||||
}
|
|
||||||
url := "http://" + host + "/api/activate-page"
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"page": page})
|
|
||||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("daemon not running? %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("switch failed (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
slog.Info("media module loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaAction is handled inline in action.go via playerctl/wpctl
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
pkgbase = streamdeck-lets-go-git
|
|
||||||
pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI
|
|
||||||
pkgver = 0.1.0
|
|
||||||
pkgrel = 3
|
|
||||||
url = https://github.com/totmin/streamdeck-lets-go
|
|
||||||
arch = x86_64
|
|
||||||
arch = aarch64
|
|
||||||
license = MIT
|
|
||||||
makedepends = go>=1.26
|
|
||||||
makedepends = git
|
|
||||||
depends = libusb
|
|
||||||
optdepends = librsvg: SVG icon rendering
|
|
||||||
optdepends = fontconfig: system font detection
|
|
||||||
provides = streamdeck-lets-go
|
|
||||||
conflicts = streamdeck-lets-go
|
|
||||||
source = streamdeck-lets-go-git::git+https://github.com/totmin/streamdeck-lets-go.git
|
|
||||||
source = streamdeck-lets-go.service
|
|
||||||
source = 90-streamdeck.rules
|
|
||||||
b2sums = SKIP
|
|
||||||
b2sums = SKIP
|
|
||||||
b2sums = SKIP
|
|
||||||
|
|
||||||
pkgname = streamdeck-lets-go-git
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Elgato Stream Deck — allow access for locally logged-in users
|
|
||||||
# Vendor 0fd9
|
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess"
|
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess"
|
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess"
|
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess"
|
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess"
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Maintainer: totmin <totmin@users.noreply.github.com>
|
|
||||||
|
|
||||||
pkgname=streamdeck-lets-go-git
|
|
||||||
_giturl=https://github.com/totmin/streamdeck-lets-go.git
|
|
||||||
pkgver=0.1.0
|
|
||||||
pkgrel=3
|
|
||||||
pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI"
|
|
||||||
arch=('x86_64' 'aarch64')
|
|
||||||
url="https://github.com/totmin/streamdeck-lets-go"
|
|
||||||
license=('MIT')
|
|
||||||
depends=('libusb')
|
|
||||||
optdepends=('librsvg: SVG icon rendering'
|
|
||||||
'fontconfig: system font detection')
|
|
||||||
makedepends=('go>=1.26' 'git')
|
|
||||||
provides=('streamdeck-lets-go')
|
|
||||||
conflicts=('streamdeck-lets-go')
|
|
||||||
|
|
||||||
source=("${pkgname}::git+${_giturl}"
|
|
||||||
"streamdeck-lets-go.service"
|
|
||||||
"90-streamdeck.rules")
|
|
||||||
b2sums=('SKIP'
|
|
||||||
'SKIP'
|
|
||||||
'SKIP')
|
|
||||||
|
|
||||||
pkgver() {
|
|
||||||
cd "${srcdir}/${pkgname}"
|
|
||||||
git describe --always --tags 2>/dev/null || echo "r0.$(git rev-parse --short HEAD)"
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "${srcdir}/${pkgname}"
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
go build \
|
|
||||||
-ldflags "-s -w" \
|
|
||||||
-o streamdeck-lets-go ./cmd/streamdeck-lets-go
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "${srcdir}/${pkgname}"
|
|
||||||
|
|
||||||
install -Dm755 streamdeck-lets-go "${pkgdir}/usr/bin/streamdeck-lets-go"
|
|
||||||
|
|
||||||
install -Dm644 "${srcdir}/streamdeck-lets-go.service" \
|
|
||||||
"${pkgdir}/usr/lib/systemd/user/streamdeck-lets-go.service"
|
|
||||||
|
|
||||||
install -Dm644 "${srcdir}/90-streamdeck.rules" \
|
|
||||||
"${pkgdir}/usr/lib/udev/rules.d/90-streamdeck.rules"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Stream Deck Lets Go daemon
|
|
||||||
Documentation=https://github.com/Wakatron/streamdeck-lets-go
|
|
||||||
After=graphical-session.target
|
|
||||||
Wants=graphical-session.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/streamdeck-lets-go daemon
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -29,7 +28,6 @@ import (
|
|||||||
"golang.org/x/image/math/fixed"
|
"golang.org/x/image/math/fixed"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type keyState struct {
|
type keyState struct {
|
||||||
@@ -46,107 +44,34 @@ type PageManager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pages map[string]*config.PageConfig
|
pages map[string]*config.PageConfig
|
||||||
active string
|
active string
|
||||||
deck *deck.Deck
|
deck *Deck
|
||||||
keyStates map[int]*keyState
|
keyStates map[int]*keyState
|
||||||
displayOutputs map[int]*DisplayOutput
|
displayOutputs map[int]*DisplayOutput
|
||||||
displayMu sync.RWMutex
|
displayMu sync.RWMutex
|
||||||
DefaultFont string
|
defaultFont string
|
||||||
ShowLabelBackground bool
|
showLabelBackground bool
|
||||||
dynamicCancel context.CancelFunc
|
|
||||||
dynamicKeys []config.KeyConfig
|
|
||||||
dynamicKeysMu sync.RWMutex
|
|
||||||
dynamicResults map[string][]config.KeyConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPageManager(deck *deck.Deck) *PageManager {
|
func NewPageManager(deck *Deck) *PageManager {
|
||||||
return &PageManager{
|
return &PageManager{
|
||||||
pages: make(map[string]*config.PageConfig),
|
pages: make(map[string]*config.PageConfig),
|
||||||
deck: deck,
|
deck: deck,
|
||||||
keyStates: make(map[int]*keyState),
|
keyStates: make(map[int]*keyState),
|
||||||
displayOutputs: make(map[int]*DisplayOutput),
|
displayOutputs: make(map[int]*DisplayOutput),
|
||||||
DefaultFont: "medium",
|
defaultFont: "medium",
|
||||||
dynamicResults: make(map[string][]config.KeyConfig),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) ActiveKeys() []config.KeyConfig {
|
|
||||||
pm.mu.RLock()
|
|
||||||
page := pm.pages[pm.active]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
if page == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.dynamicKeysMu.RLock()
|
|
||||||
dyn := pm.dynamicKeys
|
|
||||||
pm.dynamicKeysMu.RUnlock()
|
|
||||||
|
|
||||||
if dyn == nil {
|
|
||||||
return page.Keys
|
|
||||||
}
|
|
||||||
|
|
||||||
used := make(map[int]bool, len(page.Keys))
|
|
||||||
result := make([]config.KeyConfig, 0, len(page.Keys)+len(dyn))
|
|
||||||
for _, k := range page.Keys {
|
|
||||||
used[k.Index] = true
|
|
||||||
result = append(result, k)
|
|
||||||
}
|
|
||||||
for _, k := range dyn {
|
|
||||||
if !used[k.Index] {
|
|
||||||
result = append(result, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEffectiveKeys returns merged (static + cached dynamic) keys for a page.
|
|
||||||
// Used by the web UI to preview generated keys.
|
|
||||||
func (pm *PageManager) GetEffectiveKeys(name string) []config.KeyConfig {
|
|
||||||
pm.mu.RLock()
|
|
||||||
page := pm.pages[name]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
if page == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.dynamicKeysMu.RLock()
|
|
||||||
cached := pm.dynamicResults[name]
|
|
||||||
pm.dynamicKeysMu.RUnlock()
|
|
||||||
|
|
||||||
if cached == nil {
|
|
||||||
return page.Keys
|
|
||||||
}
|
|
||||||
|
|
||||||
used := make(map[int]bool, len(page.Keys))
|
|
||||||
result := make([]config.KeyConfig, 0, len(page.Keys)+len(cached))
|
|
||||||
for _, k := range page.Keys {
|
|
||||||
used[k.Index] = true
|
|
||||||
result = append(result, k)
|
|
||||||
}
|
|
||||||
for _, k := range cached {
|
|
||||||
if !used[k.Index] {
|
|
||||||
result = append(result, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) LoadPages(pages []config.PageConfig) {
|
func (pm *PageManager) LoadPages(pages []config.PageConfig) {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
pm.pages = make(map[string]*config.PageConfig, len(pages))
|
pm.pages = make(map[string]*config.PageConfig, len(pages))
|
||||||
for i := range pages {
|
for i := range pages {
|
||||||
pm.pages[pages[i].Name] = &pages[i]
|
pm.pages[pages[i].Name] = &pages[i]
|
||||||
}
|
}
|
||||||
pm.mu.Unlock()
|
|
||||||
|
|
||||||
pm.dynamicKeysMu.Lock()
|
|
||||||
pm.dynamicResults = make(map[string][]config.KeyConfig)
|
|
||||||
pm.dynamicKeysMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) ActivatePage(name string) error {
|
func (pm *PageManager) ActivatePage(name string) error {
|
||||||
pm.stopDynamicKeys()
|
|
||||||
|
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
@@ -167,7 +92,7 @@ func (pm *PageManager) ActivatePage(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if page.Background != "" {
|
if page.Background != "" {
|
||||||
if img, err := LoadImage(page.Background, 0, 0); err == nil {
|
if img, err := loadImage(page.Background, 0, 0); err == nil {
|
||||||
if err := pm.deck.FillPanel(img); err != nil {
|
if err := pm.deck.FillPanel(img); err != nil {
|
||||||
slog.Warn("fill panel failed", "error", err)
|
slog.Warn("fill panel failed", "error", err)
|
||||||
}
|
}
|
||||||
@@ -192,12 +117,6 @@ func (pm *PageManager) ActivatePage(name string) error {
|
|||||||
pm.renderKey(i, &k)
|
pm.renderKey(i, &k)
|
||||||
}
|
}
|
||||||
|
|
||||||
if page.DynamicKeys != nil {
|
|
||||||
go func() {
|
|
||||||
pm.startDynamicKeys(page.DynamicKeys)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,18 +148,14 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
|
|||||||
return pm.pages[name]
|
return pm.pages[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) GetPageNames() []string {
|
func (pm *PageManager) RenderKey(keyIndex int) {
|
||||||
pm.mu.RLock()
|
pm.mu.RLock()
|
||||||
defer pm.mu.RUnlock()
|
defer pm.mu.RUnlock()
|
||||||
names := make([]string, 0, len(pm.pages))
|
page := pm.pages[pm.active]
|
||||||
for name := range pm.pages {
|
if page == nil {
|
||||||
names = append(names, name)
|
return
|
||||||
}
|
}
|
||||||
return names
|
for _, k := range page.Keys {
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) RenderKey(keyIndex int) {
|
|
||||||
for _, k := range pm.ActiveKeys() {
|
|
||||||
if k.Index == keyIndex {
|
if k.Index == keyIndex {
|
||||||
pm.renderKey(keyIndex, &k)
|
pm.renderKey(keyIndex, &k)
|
||||||
return
|
return
|
||||||
@@ -249,7 +164,13 @@ func (pm *PageManager) RenderKey(keyIndex int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
||||||
for _, k := range pm.ActiveKeys() {
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range page.Keys {
|
||||||
if k.Index == keyIndex && k.Display != nil {
|
if k.Index == keyIndex && k.Display != nil {
|
||||||
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
|
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
|
||||||
return
|
return
|
||||||
@@ -260,7 +181,7 @@ func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
|||||||
func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
||||||
fontName := k.Font
|
fontName := k.Font
|
||||||
if fontName == "" {
|
if fontName == "" {
|
||||||
fontName = pm.DefaultFont
|
fontName = pm.defaultFont
|
||||||
}
|
}
|
||||||
fontSize := 18.0
|
fontSize := 18.0
|
||||||
if k.FontSize != nil {
|
if k.FontSize != nil {
|
||||||
@@ -273,7 +194,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if k.Icon != "" {
|
if k.Icon != "" {
|
||||||
img, err := LoadImage(k.Icon, pm.deck.KeySize(), faScale)
|
img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("load icon", "path", k.Icon, "error", err)
|
slog.Warn("load icon", "path", k.Icon, "error", err)
|
||||||
pm.deck.FillColor(idx, 64, 64, 64)
|
pm.deck.FillColor(idx, 64, 64, 64)
|
||||||
@@ -297,11 +218,11 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
}
|
}
|
||||||
onImgFontName := fontName
|
onImgFontName := fontName
|
||||||
if k.Font == "" {
|
if k.Font == "" {
|
||||||
onImgFontName = pm.DefaultFont
|
onImgFontName = pm.defaultFont
|
||||||
}
|
}
|
||||||
|
|
||||||
if k.Label != "" {
|
if k.Label != "" {
|
||||||
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.ShowLabelBackground); err != nil {
|
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.showLabelBackground); err != nil {
|
||||||
slog.Warn("write text on image", "error", err)
|
slog.Warn("write text on image", "error", err)
|
||||||
pm.deck.FillImage(idx, img)
|
pm.deck.FillImage(idx, img)
|
||||||
}
|
}
|
||||||
@@ -317,7 +238,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
ks := pm.deck.KeySize()
|
ks := pm.deck.KeySize()
|
||||||
img := image.NewRGBA(image.Rect(0, 0, ks, ks))
|
img := image.NewRGBA(image.Rect(0, 0, ks, ks))
|
||||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.ShowLabelBackground); err != nil {
|
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.showLabelBackground); err != nil {
|
||||||
slog.Warn("write text on image", "error", err)
|
slog.Warn("write text on image", "error", err)
|
||||||
pm.deck.FillImage(idx, img)
|
pm.deck.FillImage(idx, img)
|
||||||
}
|
}
|
||||||
@@ -325,7 +246,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
}
|
}
|
||||||
slog.Warn("invalid background color", "value", k.Background, "error", err)
|
slog.Warn("invalid background color", "value", k.Background, "error", err)
|
||||||
}
|
}
|
||||||
if err := pm.deck.WriteText(idx, k.Label, color.RGBA{0x21, 0x25, 0x2b, 0xff}, fontName, fontSize); err != nil {
|
if err := pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize); err != nil {
|
||||||
slog.Warn("write text", "error", err)
|
slog.Warn("write text", "error", err)
|
||||||
}
|
}
|
||||||
} else if k.Background != "" {
|
} else if k.Background != "" {
|
||||||
@@ -338,9 +259,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) StopPeriodicKeys() {
|
func (pm *PageManager) stopPeriodicKeys() {
|
||||||
pm.stopDynamicKeys()
|
|
||||||
|
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
for _, ks := range pm.keyStates {
|
for _, ks := range pm.keyStates {
|
||||||
@@ -349,7 +268,7 @@ func (pm *PageManager) StopPeriodicKeys() {
|
|||||||
pm.keyStates = make(map[int]*keyState)
|
pm.keyStates = make(map[int]*keyState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) StartPeriodicKeys() {
|
func (pm *PageManager) startPeriodicKeys() {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
page := pm.pages[pm.active]
|
page := pm.pages[pm.active]
|
||||||
if page == nil {
|
if page == nil {
|
||||||
@@ -377,136 +296,11 @@ func (pm *PageManager) StartPeriodicKeys() {
|
|||||||
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
|
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dg := page.DynamicKeys
|
|
||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
|
|
||||||
for _, dk := range keys {
|
for _, dk := range keys {
|
||||||
go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
|
go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dg != nil {
|
|
||||||
pm.startDynamicKeys(dg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) stopDynamicKeys() {
|
|
||||||
pm.dynamicKeysMu.Lock()
|
|
||||||
if pm.dynamicCancel != nil {
|
|
||||||
pm.dynamicCancel()
|
|
||||||
pm.dynamicCancel = nil
|
|
||||||
}
|
|
||||||
pm.dynamicKeys = nil
|
|
||||||
pm.dynamicKeysMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) startDynamicKeys(dg *config.DynamicKeyGen) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
pm.dynamicKeysMu.Lock()
|
|
||||||
if pm.dynamicCancel != nil {
|
|
||||||
pm.dynamicCancel()
|
|
||||||
}
|
|
||||||
pm.dynamicCancel = cancel
|
|
||||||
pm.dynamicKeysMu.Unlock()
|
|
||||||
|
|
||||||
numKeys := dg.MaxKeys
|
|
||||||
if numKeys <= 0 {
|
|
||||||
numKeys = pm.deck.NumKeys()
|
|
||||||
}
|
|
||||||
|
|
||||||
var interval time.Duration
|
|
||||||
if dg.Interval != "" {
|
|
||||||
interval, _ = time.ParseDuration(dg.Interval)
|
|
||||||
}
|
|
||||||
if interval == 0 {
|
|
||||||
interval = 60 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
go pm.runDynamicKeyGenerator(ctx, dg, numKeys, interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) runDynamicKeyGenerator(ctx context.Context, dg *config.DynamicKeyGen, numKeys int, interval time.Duration) {
|
|
||||||
timeout := 15 * time.Second
|
|
||||||
if dg.Timeout != "" {
|
|
||||||
if t, err := time.ParseDuration(dg.Timeout); err == nil && t > 0 {
|
|
||||||
timeout = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.updateDynamicKeys(dg, timeout, numKeys)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
pm.updateDynamicKeys(dg, timeout, numKeys)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) updateDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, numKeys int) {
|
|
||||||
keys, err := execDynamicKeys(dg, timeout, numKeys)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dynamic_keys generator failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) > numKeys {
|
|
||||||
keys = keys[:numKeys]
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.mu.RLock()
|
|
||||||
pageName := pm.active
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
|
|
||||||
pm.dynamicKeysMu.Lock()
|
|
||||||
if pm.dynamicCancel == nil {
|
|
||||||
pm.dynamicKeysMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pm.dynamicKeys = keys
|
|
||||||
pm.dynamicResults[pageName] = keys
|
|
||||||
pm.dynamicKeysMu.Unlock()
|
|
||||||
|
|
||||||
slog.Info("dynamic_keys updated", "count", len(keys), "page", pageName)
|
|
||||||
|
|
||||||
pm.rerenderActivePage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *PageManager) rerenderActivePage() {
|
|
||||||
pm.mu.RLock()
|
|
||||||
bg := ""
|
|
||||||
if page := pm.pages[pm.active]; page != nil {
|
|
||||||
bg = page.Background
|
|
||||||
}
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
|
|
||||||
if bg != "" {
|
|
||||||
if img, err := LoadImage(bg, 0, 0); err == nil {
|
|
||||||
pm.deck.FillPanel(img)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keys := pm.ActiveKeys()
|
|
||||||
keyByIndex := make(map[int]config.KeyConfig, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
keyByIndex[k.Index] = k
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < pm.deck.NumKeys(); i++ {
|
|
||||||
k, ok := keyByIndex[i]
|
|
||||||
if !ok {
|
|
||||||
if bg == "" {
|
|
||||||
pm.deck.ClearKey(i)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pm.renderKey(i, &k)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
|
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
|
||||||
@@ -599,8 +393,15 @@ func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
|
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var kc *config.KeyConfig
|
var kc *config.KeyConfig
|
||||||
for _, k := range pm.ActiveKeys() {
|
for _, k := range page.Keys {
|
||||||
if k.Index == idx {
|
if k.Index == idx {
|
||||||
kCopy := k
|
kCopy := k
|
||||||
kc = &kCopy
|
kc = &kCopy
|
||||||
@@ -650,7 +451,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if kc.Icon != "" {
|
if kc.Icon != "" {
|
||||||
img, err := LoadImage(kc.Icon, pm.deck.KeySize(), faScale)
|
img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("load icon for display", "path", kc.Icon, "error", err)
|
slog.Warn("load icon for display", "path", kc.Icon, "error", err)
|
||||||
var bg color.Color = color.Black
|
var bg color.Color = color.Black
|
||||||
@@ -676,7 +477,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.ShowLabelBackground)
|
composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.showLabelBackground)
|
||||||
if err := pm.deck.FillImage(idx, composite); err != nil {
|
if err := pm.deck.FillImage(idx, composite); err != nil {
|
||||||
slog.Warn("fill image", "error", err)
|
slog.Warn("fill image", "error", err)
|
||||||
}
|
}
|
||||||
@@ -698,7 +499,13 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) RefreshDisplayKey(idx int) {
|
func (pm *PageManager) RefreshDisplayKey(idx int) {
|
||||||
for _, k := range pm.ActiveKeys() {
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range page.Keys {
|
||||||
if k.Index == idx && k.Display != nil {
|
if k.Index == idx && k.Display != nil {
|
||||||
timeout := 30 * time.Second
|
timeout := 30 * time.Second
|
||||||
if k.Display.Timeout != "" {
|
if k.Display.Timeout != "" {
|
||||||
@@ -868,7 +675,7 @@ func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.
|
|||||||
return rgba
|
return rgba
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, ShowLabelBackground bool) *image.RGBA {
|
func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, showLabelBackground bool) *image.RGBA {
|
||||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||||
g.Draw(rgba, baseImg)
|
g.Draw(rgba, baseImg)
|
||||||
@@ -877,7 +684,7 @@ func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64
|
|||||||
if keySize < 72 {
|
if keySize < 72 {
|
||||||
barHeight = 16
|
barHeight = 16
|
||||||
}
|
}
|
||||||
if ShowLabelBackground {
|
if showLabelBackground {
|
||||||
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
|
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
|
||||||
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||||
}
|
}
|
||||||
@@ -1015,7 +822,7 @@ func cacheKey(path string, targetSize int, faScale float64) string {
|
|||||||
return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale)
|
return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadImage(path string, targetSize int, faScale float64) (image.Image, error) {
|
func loadImage(path string, targetSize int, faScale float64) (image.Image, error) {
|
||||||
key := cacheKey(path, targetSize, faScale)
|
key := cacheKey(path, targetSize, faScale)
|
||||||
if cached, ok := imageCache.Load(key); ok {
|
if cached, ok := imageCache.Load(key); ok {
|
||||||
return cached.(image.Image), nil
|
return cached.(image.Image), nil
|
||||||
@@ -1052,7 +859,7 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
|
|||||||
}
|
}
|
||||||
resolved = p
|
resolved = p
|
||||||
} else if !strings.HasPrefix(path, "/") {
|
} else if !strings.HasPrefix(path, "/") {
|
||||||
resolved = filepath.Join(config.ConfigDir(), path)
|
resolved = filepath.Join(configDir(), path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(resolved, ".svg") {
|
if strings.HasSuffix(resolved, ".svg") {
|
||||||
@@ -1085,9 +892,9 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
|
|||||||
displaySize = 1
|
displaySize = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize to displaySize preserving aspect ratio (crop to fill)
|
// Resize to displaySize
|
||||||
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
|
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
|
||||||
g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor))
|
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
|
||||||
scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
||||||
g.Draw(scaled, img)
|
g.Draw(scaled, img)
|
||||||
img = scaled
|
img = scaled
|
||||||
@@ -1107,70 +914,17 @@ func LoadImage(path string, targetSize int, faScale float64) (image.Image, error
|
|||||||
return img, nil
|
return img, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
|
var cachedConfigDir string
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
func configDir() string {
|
||||||
if d.Command != "" {
|
if cachedConfigDir != "" {
|
||||||
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
|
return cachedConfigDir
|
||||||
} else {
|
|
||||||
cmd = exec.CommandContext(ctx, d.Script)
|
|
||||||
}
|
}
|
||||||
var stdout, stderr bytes.Buffer
|
home, err := os.UserHomeDir()
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
output := stdout.String()
|
|
||||||
if stderr.Len() > 0 {
|
|
||||||
if output != "" {
|
|
||||||
output += "\n"
|
|
||||||
}
|
|
||||||
output += stderr.String()
|
|
||||||
}
|
|
||||||
return output, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func execDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, keyCount int) ([]config.KeyConfig, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
if dg.Command != "" {
|
|
||||||
cmd = exec.CommandContext(ctx, "sh", "-c", dg.Command)
|
|
||||||
} else {
|
|
||||||
script := dg.Script
|
|
||||||
if !filepath.IsAbs(script) {
|
|
||||||
script = filepath.Join(config.ConfigDir(), script)
|
|
||||||
}
|
|
||||||
cmd = exec.CommandContext(ctx, script)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
|
|
||||||
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", config.ConfigDir()),
|
|
||||||
)
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
|
|
||||||
if stderr.Len() > 0 {
|
|
||||||
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
|
cachedConfigDir = "."
|
||||||
|
return cachedConfigDir
|
||||||
}
|
}
|
||||||
|
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
|
||||||
var keys []config.KeyConfig
|
return cachedConfigDir
|
||||||
output := stdout.String()
|
|
||||||
if err := json.Unmarshal([]byte(output), &keys); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse dynamic_keys JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys, nil
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package render
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
@@ -32,7 +32,7 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if k.Icon != "" {
|
if k.Icon != "" {
|
||||||
img, err := LoadImage(k.Icon, keySize, faScale)
|
img, err := loadImage(k.Icon, keySize, faScale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func composeImageWithLabel(src image.Image, text string, keySize int, fontSize f
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
||||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA)
|
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
face, err := parseDisplayFace(fontSize)
|
face, err := parseDisplayFace(fontSize)
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
package daemon
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
"streamdeck-lets-go/internal/render"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Screensaver struct {
|
type Screensaver struct {
|
||||||
enabled bool
|
enabled bool
|
||||||
idleAfter time.Duration
|
idleAfter time.Duration
|
||||||
lastInput time.Time
|
lastInput time.Time
|
||||||
active bool
|
active bool
|
||||||
savedBrightness int
|
savedBrightness int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@ func (ss *Screensaver) Check() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
|
func (ss *Screensaver) Activate(deck *Deck, cfg *config.ScreensaverCfg) {
|
||||||
ss.savedBrightness = deck.Brightness()
|
ss.savedBrightness = deck.Brightness()
|
||||||
|
|
||||||
brightness := cfg.Brightness
|
brightness := cfg.Brightness
|
||||||
@@ -64,7 +62,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Image != "" {
|
if cfg.Image != "" {
|
||||||
img, err := render.LoadImage(cfg.Image, 0, 0)
|
img, err := loadImage(cfg.Image, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
|
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
|
||||||
return
|
return
|
||||||
@@ -77,7 +75,7 @@ func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
|
|||||||
slog.Info("screensaver activated")
|
slog.Info("screensaver activated")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
|
func (ss *Screensaver) Deactivate(deck *Deck) {
|
||||||
brightness := ss.savedBrightness
|
brightness := ss.savedBrightness
|
||||||
if brightness <= 0 {
|
if brightness <= 0 {
|
||||||
brightness = 75
|
brightness = 75
|
||||||
@@ -47,12 +47,45 @@ document.addEventListener('alpine:init', () => {
|
|||||||
keyboard: 'Keyboard shortcut',
|
keyboard: 'Keyboard shortcut',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Dropdown option sets (declarative — add a new dropdown = add a list) ──
|
||||||
|
actionTypeOptions: [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'command', label: 'Command' },
|
||||||
|
{ value: 'builtin', label: 'Builtin' },
|
||||||
|
{ value: 'script', label: 'Script' },
|
||||||
|
{ value: 'page', label: 'Switch page' },
|
||||||
|
{ value: 'keyboard', label: 'Keyboard shortcut' },
|
||||||
|
],
|
||||||
|
builtinOptions: [
|
||||||
|
{ value: '', label: '-- select --' },
|
||||||
|
{ value: 'volume_up', label: 'Volume Up' },
|
||||||
|
{ value: 'volume_down', label: 'Volume Down' },
|
||||||
|
{ value: 'volume_mute', label: 'Volume Mute' },
|
||||||
|
{ value: 'brightness_up', label: 'Brightness Up' },
|
||||||
|
{ value: 'brightness_down', label: 'Brightness Down' },
|
||||||
|
{ value: 'media_play_pause', label: 'Play/Pause' },
|
||||||
|
{ value: 'media_next', label: 'Next Track' },
|
||||||
|
{ value: 'media_prev', label: 'Previous Track' },
|
||||||
|
{ value: 'media_stop', label: 'Stop' },
|
||||||
|
],
|
||||||
|
fontOptions: [
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'regular', label: 'Regular' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Pages are dynamic — exposed as a getter so dropdowns stay reactive.
|
||||||
|
get pageOptions() {
|
||||||
|
return [
|
||||||
|
{ value: '', label: '-- select --' },
|
||||||
|
...this.pages.map(p => ({ value: p.name, label: p.name })),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ── Init ──
|
// ── Init ──
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadConfig()
|
await this.loadConfig()
|
||||||
await this.loadDecks()
|
await this.loadDecks()
|
||||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||||
setInterval(() => this.loadEffectiveKeys(), 10000)
|
|
||||||
this.connectSSE()
|
this.connectSSE()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -102,7 +135,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const res = await fetch('/api/config')
|
const res = await fetch('/api/config')
|
||||||
this.config = await res.json()
|
this.config = await res.json()
|
||||||
this.pages = this.config.pages
|
this.pages = this.config.pages
|
||||||
await this.loadEffectiveKeys()
|
|
||||||
if (this.pages.length > 0) {
|
if (this.pages.length > 0) {
|
||||||
const saved = localStorage.getItem('sd_active_page')
|
const saved = localStorage.getItem('sd_active_page')
|
||||||
if (saved && this.pages.find(p => p.name === saved)) {
|
if (saved && this.pages.find(p => p.name === saved)) {
|
||||||
@@ -117,19 +149,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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() {
|
syncSettings() {
|
||||||
this.settingsForm = {
|
this.settingsForm = {
|
||||||
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
||||||
@@ -166,13 +185,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
get gridKeys() {
|
get gridKeys() {
|
||||||
const page = this.currentPage
|
const page = this.currentPage
|
||||||
if (!page) return []
|
if (!page) return []
|
||||||
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
|
|
||||||
? page.effective_keys
|
|
||||||
: (page.keys || [])
|
|
||||||
const keys = []
|
const keys = []
|
||||||
const n = this.activeDeck.num_keys
|
const n = this.activeDeck.num_keys
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const kc = sourceKeys.find(k => k.index === i)
|
const kc = page.keys.find(k => k.index === i)
|
||||||
const dout = this.displayOutputs[i]
|
const dout = this.displayOutputs[i]
|
||||||
keys.push({
|
keys.push({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -181,10 +197,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
hasDisplay: !!(kc?.display),
|
hasDisplay: !!(kc?.display),
|
||||||
hasAction: !!(kc?.actions?.length),
|
hasAction: !!(kc?.actions?.length),
|
||||||
actionTypes: this.getActionTypes(kc?.actions || []),
|
actionTypes: this.getActionTypes(kc?.actions || []),
|
||||||
displayBg: (kc?.display && dout?.background) || '',
|
displayBg: dout?.background || '',
|
||||||
displayText: (kc?.display && dout?.text) || '',
|
displayText: dout?.text || '',
|
||||||
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
||||||
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
@@ -204,7 +219,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const k = kc || {}
|
const k = kc || {}
|
||||||
let url = '/api/render?key_size=72'
|
let url = '/api/render?key_size=72'
|
||||||
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||||
const label = (k.display && (dout?.text || this.displayOutputs[k.index]?.text)) || k.label
|
const label = dout?.text || this.displayOutputs[k.index]?.text || k.label
|
||||||
if (label) url += `&label=${encodeURIComponent(label)}`
|
if (label) url += `&label=${encodeURIComponent(label)}`
|
||||||
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
|
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
|
||||||
if (k.font_size != null) url += `&font_size=${k.font_size}`
|
if (k.font_size != null) url += `&font_size=${k.font_size}`
|
||||||
@@ -325,9 +340,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const page = this.currentPage
|
const page = this.currentPage
|
||||||
if (!page) return
|
if (!page) return
|
||||||
const kc = page.keys.find(k => k.index === idx)
|
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.editing = idx
|
||||||
this.showAdvanced = false
|
this.showAdvanced = false
|
||||||
|
|
||||||
@@ -742,4 +754,74 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return m[1].replace('T', ' ')
|
return m[1].replace('T', ' ')
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// ── Reusable dropdown component ──
|
||||||
|
// Usage:
|
||||||
|
// x-data="dropdown({ get: () => model, set: v => model = v, options: [...] })"
|
||||||
|
// `options` may be an array or a function returning one (for reactive lists).
|
||||||
|
Alpine.data('dropdown', (config = {}) => ({
|
||||||
|
open: false,
|
||||||
|
activeIndex: -1,
|
||||||
|
_get: config.get || (() => ''),
|
||||||
|
_set: config.set || (() => {}),
|
||||||
|
_options: config.options || [],
|
||||||
|
placeholder: config.placeholder || '-- select --',
|
||||||
|
|
||||||
|
get optionList() {
|
||||||
|
return typeof this._options === 'function' ? this._options() : this._options
|
||||||
|
},
|
||||||
|
get selectedValue() {
|
||||||
|
return this._get()
|
||||||
|
},
|
||||||
|
get selectedLabel() {
|
||||||
|
const opt = this.optionList.find(o => o.value === this.selectedValue)
|
||||||
|
return opt ? opt.label : this.placeholder
|
||||||
|
},
|
||||||
|
get isPlaceholder() {
|
||||||
|
const v = this.selectedValue
|
||||||
|
const opt = this.optionList.find(o => o.value === v)
|
||||||
|
return v === '' || v == null || !opt
|
||||||
|
},
|
||||||
|
isSelected(value) {
|
||||||
|
return value === this.selectedValue
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
this.open = !this.open
|
||||||
|
if (this.open) {
|
||||||
|
this.activeIndex = this.optionList.findIndex(o => o.value === this.selectedValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.open = false
|
||||||
|
this.activeIndex = -1
|
||||||
|
},
|
||||||
|
select(value) {
|
||||||
|
this._set(value)
|
||||||
|
this.close()
|
||||||
|
},
|
||||||
|
onTriggerKey(e) {
|
||||||
|
if (!this.open) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
this.toggle()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = this.optionList
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
this.activeIndex = (this.activeIndex + 1) % list.length
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
this.activeIndex = (this.activeIndex - 1 + list.length) % list.length
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (this.activeIndex >= 0) this.select(list[this.activeIndex].value)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation() // keep Escape from also closing the parent modal
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
@@ -94,11 +94,8 @@
|
|||||||
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
||||||
@click="switchPage(p.name); subView = null">
|
@click="switchPage(p.name); subView = null">
|
||||||
<span x-text="p.name"></span>
|
<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;"
|
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
||||||
x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
|
x-text="p.keys.length + ' keys'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,10 +117,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Default Font</label>
|
<label>Default Font</label>
|
||||||
<select x-model="settingsForm.font">
|
<div class="dropdown" x-data="dropdown({ get: () => settingsForm.font, set: v => settingsForm.font = v, options: fontOptions })" @click.outside="close()">
|
||||||
<option value="medium">Medium</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<option value="regular">Regular</option>
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
</select>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
|
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
|
||||||
@@ -188,12 +195,20 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="margin-bottom:0;">
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
<label>Page</label>
|
<label>Page</label>
|
||||||
<select x-model="rule.page">
|
<div class="dropdown" x-data="dropdown({ get: () => rule.page, set: v => rule.page = v, options: () => pageOptions })" @click.outside="close()">
|
||||||
<option value="">--</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<template x-for="p in pages" :key="p.name">
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option :value="p.name" x-text="p.name"></option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
</template>
|
</button>
|
||||||
</select>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
|
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
|
||||||
<input type="checkbox" x-model="rule.stay">
|
<input type="checkbox" x-model="rule.stay">
|
||||||
@@ -304,14 +319,20 @@
|
|||||||
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
|
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
|
||||||
<div x-show="editForm.activeTrigger === t">
|
<div x-show="editForm.activeTrigger === t">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select x-model="editForm.actions[t].type">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].type, set: v => editForm.actions[t].type = v, options: actionTypeOptions })" @click.outside="close()">
|
||||||
<option value="none">None</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<option value="command">Command</option>
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option value="builtin">Builtin</option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
<option value="script">Script</option>
|
</button>
|
||||||
<option value="page">Switch page</option>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
<option value="keyboard">Keyboard shortcut</option>
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
</select>
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="editForm.actions[t].type === 'command'">
|
<template x-if="editForm.actions[t].type === 'command'">
|
||||||
@@ -328,18 +349,20 @@
|
|||||||
|
|
||||||
<template x-if="editForm.actions[t].type === 'builtin'">
|
<template x-if="editForm.actions[t].type === 'builtin'">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select x-model="editForm.actions[t].builtin">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].builtin, set: v => editForm.actions[t].builtin = v, options: builtinOptions })" @click.outside="close()">
|
||||||
<option value="">-- select --</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<option value="volume_up">Volume Up</option>
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option value="volume_down">Volume Down</option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
<option value="volume_mute">Volume Mute</option>
|
</button>
|
||||||
<option value="brightness_up">Brightness Up</option>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
<option value="brightness_down">Brightness Down</option>
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
<option value="media_play_pause">Play/Pause</option>
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
<option value="media_next">Next Track</option>
|
<span x-text="opt.label"></span>
|
||||||
<option value="media_prev">Previous Track</option>
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
<option value="media_stop">Stop</option>
|
</button>
|
||||||
</select>
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -350,12 +373,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
|
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
|
||||||
<select x-model="editForm.actions[t].page">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].page, set: v => editForm.actions[t].page = v, options: () => pageOptions })" @click.outside="close()">
|
||||||
<option value="">-- select --</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<template x-for="p in pages" :key="p.name">
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option :value="p.name" x-text="p.name"></option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
</template>
|
</button>
|
||||||
</select>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="editForm.actions[t].type === 'keyboard'">
|
<template x-if="editForm.actions[t].type === 'keyboard'">
|
||||||
@@ -401,14 +432,20 @@
|
|||||||
<template x-for="phase in ['start','end']" :key="phase">
|
<template x-for="phase in ['start','end']" :key="phase">
|
||||||
<div x-show="editForm.holdPhase === phase">
|
<div x-show="editForm.holdPhase === phase">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select x-model="editForm.actions.hold[phase].type">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].type, set: v => editForm.actions.hold[phase].type = v, options: actionTypeOptions })" @click.outside="close()">
|
||||||
<option value="none">None</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<option value="command">Command</option>
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option value="builtin">Builtin</option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
<option value="script">Script</option>
|
</button>
|
||||||
<option value="page">Switch page</option>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
<option value="keyboard">Keyboard shortcut</option>
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
</select>
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="editForm.actions.hold[phase].type === 'command'">
|
<template x-if="editForm.actions.hold[phase].type === 'command'">
|
||||||
@@ -425,18 +462,20 @@
|
|||||||
|
|
||||||
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
|
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select x-model="editForm.actions.hold[phase].builtin">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].builtin, set: v => editForm.actions.hold[phase].builtin = v, options: builtinOptions })" @click.outside="close()">
|
||||||
<option value="">-- select --</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<option value="volume_up">Volume Up</option>
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option value="volume_down">Volume Down</option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
<option value="volume_mute">Volume Mute</option>
|
</button>
|
||||||
<option value="brightness_up">Brightness Up</option>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
<option value="brightness_down">Brightness Down</option>
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
<option value="media_play_pause">Play/Pause</option>
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
<option value="media_next">Next Track</option>
|
<span x-text="opt.label"></span>
|
||||||
<option value="media_prev">Previous Track</option>
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
<option value="media_stop">Stop</option>
|
</button>
|
||||||
</select>
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -447,12 +486,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
|
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
|
||||||
<select x-model="editForm.actions.hold[phase].page">
|
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].page, set: v => editForm.actions.hold[phase].page = v, options: () => pageOptions })" @click.outside="close()">
|
||||||
<option value="">-- select --</option>
|
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
|
||||||
<template x-for="p in pages" :key="p.name">
|
<span class="dropdown-text" x-text="selectedLabel"></span>
|
||||||
<option :value="p.name" x-text="p.name"></option>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
</template>
|
</button>
|
||||||
</select>
|
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
|
<template x-for="(opt, oi) in optionList" :key="opt.value">
|
||||||
|
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
|
||||||
|
<span x-text="opt.label"></span>
|
||||||
|
<i class="fas fa-check dropdown-check"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">
|
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">
|
||||||
@@ -1,24 +1,52 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0a0a10;
|
/* ── One Dark Pro palette ── */
|
||||||
--surface: #141420;
|
/* Surfaces: layered dark greys built around One Dark's #282c34 base */
|
||||||
--surface-hover: #1a1a2a;
|
--bg: #282c34;
|
||||||
--border: #252540;
|
--surface: #2f343f;
|
||||||
--border-light: #333358;
|
--surface-hover: #3a3f4b;
|
||||||
--text: #e4e4f0;
|
--surface-sunken: #21252b;
|
||||||
--text-muted: #7a7a90;
|
--border: #3e4451;
|
||||||
--accent: #6366f1;
|
--border-light: #4b5263;
|
||||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
|
||||||
--danger: #ef4444;
|
/* Text */
|
||||||
--success: #22c55e;
|
--text: #abb2bf;
|
||||||
|
--text-muted: #5c6370;
|
||||||
|
|
||||||
|
/* Accent — purple (One Dark Pro signature) */
|
||||||
|
--accent: #c678dd;
|
||||||
|
--accent-hover: #d292e8;
|
||||||
|
--accent-active: #b561d0;
|
||||||
|
--accent-glow: rgba(198, 120, 221, 0.28);
|
||||||
|
--accent-soft: rgba(198, 120, 221, 0.12);
|
||||||
|
--on-accent: #21252b; /* dark text on the bright accent — WCAG AA (5.3:1) */
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--danger: #e06c75;
|
||||||
|
--danger-soft: rgba(224, 108, 117, 0.12);
|
||||||
|
--success: #98c379;
|
||||||
|
--warning: #e5c07b;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-xs: 6px;
|
--radius-xs: 6px;
|
||||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
|
/* Elevation */
|
||||||
|
--shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
/* Motion */
|
||||||
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-spring: 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
||||||
@@ -112,6 +140,7 @@ main {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 525px;
|
max-width: 525px;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
@@ -140,7 +169,7 @@ main {
|
|||||||
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
|
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||||
.deck-btn.active {
|
.deck-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +177,7 @@ main {
|
|||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: #0d0d18;
|
background: var(--surface-sunken);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -164,8 +193,9 @@ main {
|
|||||||
|
|
||||||
.key-btn:hover {
|
.key-btn:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 12px var(--accent-glow);
|
box-shadow: 0 0 0 1px var(--accent), 0 4px 16px var(--accent-glow);
|
||||||
transform: scale(1.03);
|
transform: scale(1.04);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-btn:active { transform: scale(0.97); }
|
.key-btn:active { transform: scale(0.97); }
|
||||||
@@ -210,8 +240,9 @@ main {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -257,7 +288,7 @@ main {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0d0d18;
|
background: var(--surface-sunken);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,38 +334,139 @@ main {
|
|||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.form-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 9px 11px;
|
||||||
background: var(--bg);
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group select:focus {
|
.form-group select:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input::placeholder {
|
.form-group input::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.5;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group select {
|
.form-group select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
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-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='%235c6370' stroke-width='1.5'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 10px center;
|
background-position: right 10px center;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Custom dropdown ──
|
||||||
|
Inline-expand pattern: the menu lives in normal document flow, so it
|
||||||
|
can never be clipped by an ancestor with overflow:auto (e.g. the modal).
|
||||||
|
Reusable across every <select> replacement. */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger:hover { border-color: var(--border-light); }
|
||||||
|
|
||||||
|
.dropdown.open > .dropdown-trigger,
|
||||||
|
.dropdown-trigger:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger .dropdown-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dropdown-trigger.is-placeholder .dropdown-text { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.dropdown.open > .dropdown-trigger .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 260px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option:hover,
|
||||||
|
.dropdown-option.active {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option.selected {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option .dropdown-check {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dropdown-option.selected .dropdown-check { opacity: 1; }
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -401,18 +533,23 @@ main {
|
|||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: #4f46e5;
|
background: var(--accent-hover);
|
||||||
box-shadow: 0 0 16px var(--accent-glow);
|
border-color: var(--accent-hover);
|
||||||
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:active { background: var(--accent-active); }
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
@@ -655,7 +792,7 @@ main {
|
|||||||
.tab-btn:hover { color: var(--text); }
|
.tab-btn:hover { color: var(--text); }
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Modifier buttons ── */
|
/* ── Modifier buttons ── */
|
||||||
@@ -681,7 +818,7 @@ main {
|
|||||||
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
|
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||||
.mod-btn.active {
|
.mod-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +908,7 @@ main {
|
|||||||
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
|
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
|
||||||
.action-tab.active {
|
.action-tab.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.action-tab.configured {
|
.action-tab.configured {
|
||||||
@@ -780,7 +917,7 @@ main {
|
|||||||
}
|
}
|
||||||
.action-tab.active.configured {
|
.action-tab.active.configured {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.tab-check { font-size: 8px; }
|
.tab-check { font-size: 8px; }
|
||||||
@@ -808,6 +945,6 @@ main {
|
|||||||
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
|
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
|
||||||
.hold-stab.active {
|
.hold-stab.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamdeck-lets-go/internal/config"
|
"streamdeck-lets-go/internal/config"
|
||||||
"streamdeck-lets-go/internal/deck"
|
|
||||||
"streamdeck-lets-go/internal/render"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
@@ -38,9 +36,9 @@ func init() {
|
|||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
pm *render.PageManager
|
pm *PageManager
|
||||||
extraPMs []*render.PageManager
|
extraPMs []*PageManager
|
||||||
decks []*deck.Deck
|
decks []*Deck
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
sseClients map[chan string]struct{}
|
sseClients map[chan string]struct{}
|
||||||
@@ -61,19 +59,19 @@ func (s *WebServer) UpdateConfig(cfg *config.Config) {
|
|||||||
s.cfg = cfg
|
s.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) SetPageManager(pm *render.PageManager) {
|
func (s *WebServer) SetPageManager(pm *PageManager) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.pm = pm
|
s.pm = pm
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) SetDecks(decks []*deck.Deck) {
|
func (s *WebServer) SetDecks(decks []*Deck) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.decks = decks
|
s.decks = decks
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
|
func (s *WebServer) SetExtraPageManagers(pms []*PageManager) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.extraPMs = pms
|
s.extraPMs = pms
|
||||||
@@ -174,14 +172,6 @@ func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sseClients[ch] = struct{}{}
|
s.sseClients[ch] = struct{}{}
|
||||||
s.sseMu.Unlock()
|
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()
|
notify := r.Context().Done()
|
||||||
go func() {
|
go func() {
|
||||||
<-notify
|
<-notify
|
||||||
@@ -218,7 +208,7 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
|
allPMs := append([]*PageManager{s.pm}, s.extraPMs...)
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
for _, pm := range allPMs {
|
for _, pm := range allPMs {
|
||||||
@@ -228,8 +218,8 @@ func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := pm.ActivatePage(req.Page); err != nil {
|
if err := pm.ActivatePage(req.Page); err != nil {
|
||||||
slog.Warn("activate page", "page", req.Page, "error", err)
|
slog.Warn("activate page", "page", req.Page, "error", err)
|
||||||
} else {
|
} else {
|
||||||
pm.StopPeriodicKeys()
|
pm.stopPeriodicKeys()
|
||||||
pm.StartPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,27 +339,18 @@ func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
type pageInfo struct {
|
type pageInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Keys []config.KeyConfig `json:"keys"`
|
Keys []config.KeyConfig `json:"keys"`
|
||||||
Icon string `json:"icon,omitempty"`
|
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))
|
pages := make([]pageInfo, 0, len(s.cfg.Pages))
|
||||||
for _, p := range s.cfg.Pages {
|
for _, p := range s.cfg.Pages {
|
||||||
pi := pageInfo{
|
pages = append(pages, pageInfo{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Keys: p.Keys,
|
Keys: p.Keys,
|
||||||
Icon: p.Icon,
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -423,7 +404,7 @@ func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
|
|||||||
kc.Background = bg
|
kc.Background = bg
|
||||||
}
|
}
|
||||||
|
|
||||||
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
|
img := RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "image/png")
|
w.Header().Set("Content-Type", "image/png")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
@@ -489,7 +470,7 @@ func collectIcons(cfg *config.Config, used map[string]bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gcImages(cfg *config.Config) {
|
func gcImages(cfg *config.Config) {
|
||||||
imgDir := filepath.Join(config.ConfigDir(), "images")
|
imgDir := filepath.Join(configDir(), "images")
|
||||||
entries, err := os.ReadDir(imgDir)
|
entries, err := os.ReadDir(imgDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -498,7 +479,7 @@ func gcImages(cfg *config.Config) {
|
|||||||
used := make(map[string]bool)
|
used := make(map[string]bool)
|
||||||
collectIcons(cfg, used)
|
collectIcons(cfg, used)
|
||||||
|
|
||||||
backupDir := filepath.Join(config.ConfigDir(), "backups")
|
backupDir := filepath.Join(configDir(), "backups")
|
||||||
backupEntries, _ := os.ReadDir(backupDir)
|
backupEntries, _ := os.ReadDir(backupDir)
|
||||||
for _, be := range backupEntries {
|
for _, be := range backupEntries {
|
||||||
if be.IsDir() {
|
if be.IsDir() {
|
||||||
@@ -600,15 +581,15 @@ func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
|
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
|
||||||
type modelInfo struct {
|
type modelInfo struct {
|
||||||
PID uint16 `json:"pid"`
|
PID uint16 `json:"pid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
KeysX int `json:"keys_x"`
|
KeysX int `json:"keys_x"`
|
||||||
KeysY int `json:"keys_y"`
|
KeysY int `json:"keys_y"`
|
||||||
KeySize int `json:"key_size"`
|
KeySize int `json:"key_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
models := make([]modelInfo, 0, len(deck.KnownDecks))
|
models := make([]modelInfo, 0, len(knownDecks))
|
||||||
for _, d := range deck.KnownDecks {
|
for _, d := range knownDecks {
|
||||||
models = append(models, modelInfo{
|
models = append(models, modelInfo{
|
||||||
PID: d.PID,
|
PID: d.PID,
|
||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
@@ -626,7 +607,7 @@ func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
infos := make([]deck.DeckInfo, 0, len(s.decks))
|
infos := make([]DeckInfo, 0, len(s.decks))
|
||||||
for _, d := range s.decks {
|
for _, d := range s.decks {
|
||||||
infos = append(infos, d.DeckInfo())
|
infos = append(infos, d.DeckInfo())
|
||||||
}
|
}
|
||||||
@@ -663,7 +644,7 @@ func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
uploadDir := filepath.Join(config.ConfigDir(), "images")
|
uploadDir := filepath.Join(configDir(), "images")
|
||||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
Reference in New Issue
Block a user