This commit is contained in:
@@ -18,8 +18,11 @@ jobs:
|
||||
run: bash scripts/ci-setup-go.sh
|
||||
env:
|
||||
GO_VERSION: ${{ env.GO_VERSION }}
|
||||
- run: go test ./...
|
||||
- run: go build -o bin/hp-viz ./cmd/hp-viz/
|
||||
- name: Install audio deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config
|
||||
- run: CGO_ENABLED=1 go test ./...
|
||||
- run: CGO_ENABLED=0 go test -tags nosound ./...
|
||||
- run: CGO_ENABLED=0 go build -o bin/hp-viz ./cmd/hp-viz/
|
||||
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
@@ -19,6 +19,8 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/ebitengine/oto/v3 v3.4.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
@@ -22,6 +22,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
|
||||
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package sound
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVolume = 0.3
|
||||
maxAttackSoundsPerSec = 4
|
||||
)
|
||||
|
||||
// Engine plays HoneyPeer viz event sounds (ported from web useVizSound.ts).
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
volume float64
|
||||
reduced bool
|
||||
|
||||
attackMu sync.Mutex
|
||||
attackTimes []time.Time
|
||||
}
|
||||
|
||||
func New() *Engine {
|
||||
return &Engine{
|
||||
volume: defaultVolume,
|
||||
reduced: envTruthy("HP_VIZ_REDUCED_MOTION"),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Enabled() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.enabled
|
||||
}
|
||||
|
||||
func (e *Engine) SetEnabled(v bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if v && e.reduced {
|
||||
return
|
||||
}
|
||||
e.enabled = v
|
||||
}
|
||||
|
||||
func (e *Engine) Toggle() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.enabled && e.reduced {
|
||||
return false
|
||||
}
|
||||
e.enabled = !e.enabled
|
||||
return e.enabled
|
||||
}
|
||||
|
||||
func (e *Engine) Close() {
|
||||
closePlayback()
|
||||
}
|
||||
|
||||
func (e *Engine) PlayAttack() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
scale := e.attackVolScale()
|
||||
if scale <= 0 {
|
||||
return
|
||||
}
|
||||
vol := e.volume * scale
|
||||
e.output(renderSweep(900, 150, 300*time.Millisecond, waveSawtooth, vol))
|
||||
e.output(renderNoise(100*time.Millisecond, vol))
|
||||
}
|
||||
|
||||
func (e *Engine) PlayBlock() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
e.output(renderTone(70, 350*time.Millisecond, waveSine, e.volume))
|
||||
}
|
||||
|
||||
func (e *Engine) PlayModeration() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
vol := e.volume
|
||||
e.output(renderTone(550, 100*time.Millisecond, waveSine, vol))
|
||||
time.AfterFunc(90*time.Millisecond, func() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
e.output(renderTone(880, 120*time.Millisecond, waveSine, vol))
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) PlayPeerOnline() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
e.output(renderSweep(350, 750, 180*time.Millisecond, waveSine, e.volume))
|
||||
}
|
||||
|
||||
func (e *Engine) PlayPeerOffline() {
|
||||
if !e.Enabled() {
|
||||
return
|
||||
}
|
||||
e.output(renderSweep(600, 200, 250*time.Millisecond, waveSine, e.volume))
|
||||
}
|
||||
|
||||
func (e *Engine) attackVolScale() float64 {
|
||||
e.attackMu.Lock()
|
||||
defer e.attackMu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-time.Second)
|
||||
recent := e.attackTimes[:0]
|
||||
for _, t := range e.attackTimes {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= maxAttackSoundsPerSec {
|
||||
e.attackTimes = recent
|
||||
return 0
|
||||
}
|
||||
e.attackTimes = append(recent, now)
|
||||
if len(recent) >= 2 {
|
||||
return 0.55
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (e *Engine) output(mono []int16) {
|
||||
if len(mono) == 0 {
|
||||
return
|
||||
}
|
||||
_ = playPCM(monoToStereoBytes(mono))
|
||||
}
|
||||
|
||||
func monoToStereoBytes(mono []int16) []byte {
|
||||
buf := make([]byte, len(mono)*4)
|
||||
for i, s := range mono {
|
||||
binary.LittleEndian.PutUint16(buf[i*4:], uint16(s))
|
||||
binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(s))
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func envTruthy(key string) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
func pcmReader(pcm []byte) *bytes.Reader {
|
||||
return bytes.NewReader(pcm)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build cgo && !nosound
|
||||
|
||||
package sound
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ebitengine/oto/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
audioOnce sync.Once
|
||||
audioCtx *oto.Context
|
||||
audioErr error
|
||||
audioReady chan struct{}
|
||||
|
||||
playersMu sync.Mutex
|
||||
players []*oto.Player
|
||||
)
|
||||
|
||||
func initAudio() {
|
||||
op := &oto.NewContextOptions{
|
||||
SampleRate: sampleRate,
|
||||
ChannelCount: 2,
|
||||
Format: oto.FormatSignedInt16LE,
|
||||
}
|
||||
ctx, ready, err := oto.NewContext(op)
|
||||
if err != nil {
|
||||
audioErr = err
|
||||
return
|
||||
}
|
||||
audioCtx = ctx
|
||||
audioReady = ready
|
||||
}
|
||||
|
||||
func playPCM(pcm []byte) error {
|
||||
audioOnce.Do(initAudio)
|
||||
if audioErr != nil {
|
||||
return audioErr
|
||||
}
|
||||
if audioCtx == nil {
|
||||
return audioErr
|
||||
}
|
||||
if audioReady != nil {
|
||||
<-audioReady
|
||||
audioReady = nil
|
||||
}
|
||||
|
||||
p := audioCtx.NewPlayer(pcmReader(pcm))
|
||||
p.Play()
|
||||
|
||||
playersMu.Lock()
|
||||
players = append(players, p)
|
||||
playersMu.Unlock()
|
||||
|
||||
go func(player *oto.Player) {
|
||||
for player.IsPlaying() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
playersMu.Lock()
|
||||
for i, cur := range players {
|
||||
if cur == player {
|
||||
players = append(players[:i], players[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
playersMu.Unlock()
|
||||
runtime.KeepAlive(player)
|
||||
}(p)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func closePlayback() {
|
||||
playersMu.Lock()
|
||||
defer playersMu.Unlock()
|
||||
for _, p := range players {
|
||||
_ = p.Close()
|
||||
}
|
||||
players = nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build !cgo && !nosound
|
||||
|
||||
package sound
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// playPCM pipes raw stereo PCM to aplay (Linux) or afplay via a WAV wrapper (macOS).
|
||||
func playPCM(pcm []byte) error {
|
||||
if len(pcm) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if path, err := exec.LookPath("aplay"); err == nil {
|
||||
cmd := exec.Command(path, "-q", "-t", "raw", "-f", "S16_LE", "-r", "44100", "-c", "2")
|
||||
cmd.Stdin = bytes.NewReader(pcm)
|
||||
return cmd.Start()
|
||||
}
|
||||
if path, err := exec.LookPath("paplay"); err == nil {
|
||||
cmd := exec.Command(path, "--raw", "--rate=44100", "--channels=2", "--format=s16le")
|
||||
cmd.Stdin = bytes.NewReader(pcm)
|
||||
return cmd.Start()
|
||||
}
|
||||
case "darwin":
|
||||
if path, err := exec.LookPath("afplay"); err == nil {
|
||||
wav := wrapWAV(pcm, sampleRate, 2)
|
||||
cmd := exec.Command(path, "-")
|
||||
cmd.Stdin = bytes.NewReader(wav)
|
||||
return cmd.Start()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closePlayback() {}
|
||||
|
||||
func wrapWAV(pcm []byte, rate, channels int) []byte {
|
||||
dataLen := len(pcm)
|
||||
buf := make([]byte, 44+dataLen)
|
||||
copy(buf[0:4], "RIFF")
|
||||
binary.LittleEndian.PutUint32(buf[4:8], uint32(36+dataLen))
|
||||
copy(buf[8:12], "WAVE")
|
||||
copy(buf[12:16], "fmt ")
|
||||
binary.LittleEndian.PutUint32(buf[16:20], 16)
|
||||
binary.LittleEndian.PutUint16(buf[20:22], 1)
|
||||
binary.LittleEndian.PutUint16(buf[22:24], uint16(channels))
|
||||
binary.LittleEndian.PutUint32(buf[24:28], uint32(rate))
|
||||
byteRate := rate * channels * 2
|
||||
binary.LittleEndian.PutUint32(buf[28:32], uint32(byteRate))
|
||||
binary.LittleEndian.PutUint16(buf[32:34], uint16(channels*2))
|
||||
binary.LittleEndian.PutUint16(buf[34:36], 16)
|
||||
copy(buf[36:40], "data")
|
||||
binary.LittleEndian.PutUint32(buf[40:44], uint32(dataLen))
|
||||
copy(buf[44:], pcm)
|
||||
return buf
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build nosound
|
||||
|
||||
package sound
|
||||
|
||||
func playPCM([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func closePlayback() {}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sound
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleRate = 44100
|
||||
|
||||
type waveKind int
|
||||
|
||||
const (
|
||||
waveSine waveKind = iota
|
||||
waveSawtooth
|
||||
)
|
||||
|
||||
func envelope(t, durSec, vol float64) float64 {
|
||||
if vol <= 0 || durSec <= 0 {
|
||||
return 0
|
||||
}
|
||||
return vol * math.Pow(0.001/vol, t/durSec)
|
||||
}
|
||||
|
||||
func renderTone(freq float64, dur time.Duration, kind waveKind, vol float64) []int16 {
|
||||
durSec := dur.Seconds()
|
||||
n := int(float64(sampleRate) * durSec)
|
||||
if n < 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int16, n)
|
||||
var phase float64
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / float64(sampleRate)
|
||||
g := envelope(t, durSec, vol)
|
||||
phase += 2 * math.Pi * freq / float64(sampleRate)
|
||||
s := waveSample(phase, kind) * g
|
||||
out[i] = clampInt16(s * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderSweep(from, to float64, dur time.Duration, kind waveKind, vol float64) []int16 {
|
||||
durSec := dur.Seconds()
|
||||
n := int(float64(sampleRate) * durSec)
|
||||
if n < 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int16, n)
|
||||
var phase float64
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / float64(sampleRate)
|
||||
g := envelope(t, durSec, vol)
|
||||
frac := t / durSec
|
||||
freq := from * math.Pow(to/from, frac)
|
||||
phase += 2 * math.Pi * freq / float64(sampleRate)
|
||||
s := waveSample(phase, kind) * g
|
||||
out[i] = clampInt16(s * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderNoise(dur time.Duration, vol float64) []int16 {
|
||||
durSec := dur.Seconds()
|
||||
n := int(float64(sampleRate) * durSec)
|
||||
if n < 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int16, n)
|
||||
noiseVol := vol * 0.25
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / float64(sampleRate)
|
||||
g := envelope(t, durSec, noiseVol)
|
||||
s := (rand.Float64()*2 - 1) * g
|
||||
out[i] = clampInt16(s * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waveSample(phase float64, kind waveKind) float64 {
|
||||
switch kind {
|
||||
case waveSawtooth:
|
||||
p := phase / (2 * math.Pi)
|
||||
return 2 * (p - math.Floor(p+0.5))
|
||||
default:
|
||||
return math.Sin(phase)
|
||||
}
|
||||
}
|
||||
|
||||
func clampInt16(v float64) int16 {
|
||||
if v > 32767 {
|
||||
return 32767
|
||||
}
|
||||
if v < -32768 {
|
||||
return -32768
|
||||
}
|
||||
return int16(v)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package sound
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAttackVolScale(t *testing.T) {
|
||||
e := New()
|
||||
e.SetEnabled(true)
|
||||
|
||||
if got := e.attackVolScale(); got != 1 {
|
||||
t.Fatalf("first scale = %v, want 1", got)
|
||||
}
|
||||
if got := e.attackVolScale(); got != 1 {
|
||||
t.Fatalf("second scale = %v, want 1", got)
|
||||
}
|
||||
if got := e.attackVolScale(); got != 0.55 {
|
||||
t.Fatalf("third scale = %v, want 0.55", got)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
e.attackVolScale()
|
||||
}
|
||||
if got := e.attackVolScale(); got != 0 {
|
||||
t.Fatalf("rate limited scale = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToneLength(t *testing.T) {
|
||||
buf := renderTone(440, 100*time.Millisecond, waveSine, 0.3)
|
||||
want := int(float64(sampleRate) * 0.1)
|
||||
if len(buf) != want {
|
||||
t.Fatalf("len = %d, want %d", len(buf), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReducedMotionBlocksEnable(t *testing.T) {
|
||||
t.Setenv("HP_VIZ_REDUCED_MOTION", "1")
|
||||
e := New()
|
||||
if e.Toggle() {
|
||||
t.Fatal("expected toggle to stay disabled under reduced motion")
|
||||
}
|
||||
if e.Enabled() {
|
||||
t.Fatal("expected engine disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggle(t *testing.T) {
|
||||
e := New()
|
||||
if e.Enabled() {
|
||||
t.Fatal("expected disabled by default")
|
||||
}
|
||||
if !e.Toggle() {
|
||||
t.Fatal("expected enabled after toggle")
|
||||
}
|
||||
if e.Toggle() {
|
||||
t.Fatal("expected disabled after second toggle")
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func writeWrappedField(b *strings.Builder, label, value string, width, labelWidt
|
||||
}
|
||||
}
|
||||
|
||||
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, tab ViewTab, feedMeshVisible bool, historyNote string) string {
|
||||
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, tab ViewTab, feedMeshVisible, soundEnabled bool, historyNote string) string {
|
||||
var hints []string
|
||||
if searching {
|
||||
hints = []string{"type id…", "Enter lookup", "Esc cancel"}
|
||||
@@ -269,6 +269,7 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
|
||||
if tab == TabFeed {
|
||||
hints = append(hints, "v mesh")
|
||||
}
|
||||
hints = append(hints, "e sound")
|
||||
hints = append(hints, "m mouse")
|
||||
if filtering {
|
||||
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
|
||||
@@ -286,6 +287,9 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
|
||||
if tab == TabFeed && feedMeshVisible {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mesh]")
|
||||
}
|
||||
if soundEnabled {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[sound]")
|
||||
}
|
||||
if historyNote != "" {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Muted).Render("[" + historyNote + "]")
|
||||
}
|
||||
@@ -304,7 +308,8 @@ func renderHelp(t Theme) string {
|
||||
PgUp Load older attacks (Feed) / moderation (Incidents) / more posts (Blog)
|
||||
|
||||
Feed tab
|
||||
v Toggle peer mesh animation above the feed
|
||||
v Toggle peer mesh animation below the feed
|
||||
e Toggle event sounds (attack, block, peer, moderation)
|
||||
|
||||
Blog tab
|
||||
Enter Read selected briefing
|
||||
@@ -346,6 +351,7 @@ func renderHelp(t Theme) string {
|
||||
r Force SSE reconnect
|
||||
|
||||
General
|
||||
e Toggle synthesized event sounds (matches web viz)
|
||||
? Toggle this help
|
||||
q, Ctrl+C Quit
|
||||
`
|
||||
|
||||
@@ -48,7 +48,7 @@ func (m Model) chromeBottom() string {
|
||||
if m.statusErr != "" {
|
||||
parts = append(parts, renderStatusErr(m.theme, m.statusErr))
|
||||
}
|
||||
parts = append(parts, renderFooter(m.theme, m.filtering, m.searching, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.tab, m.feedMeshVisible, m.historyFooterNote()))
|
||||
parts = append(parts, renderFooter(m.theme, m.filtering, m.searching, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.tab, m.feedMeshVisible, m.sound.Enabled(), m.historyFooterNote()))
|
||||
if m.filtering {
|
||||
parts = append(parts, m.filterInput.View())
|
||||
}
|
||||
|
||||
+30
-6
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/honeypeer/cli-viz/internal/config"
|
||||
"github.com/honeypeer/cli-viz/internal/mesh"
|
||||
"github.com/honeypeer/cli-viz/internal/sound"
|
||||
"github.com/honeypeer/cli-viz/internal/transport"
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
@@ -63,6 +64,7 @@ type keyMap struct {
|
||||
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Enter, Reconnect, Close, Mouse key.Binding
|
||||
PgUp, PgDn key.Binding
|
||||
MeshView key.Binding
|
||||
Sound key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
@@ -82,6 +84,7 @@ func defaultKeyMap() keyMap {
|
||||
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
|
||||
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
|
||||
MeshView: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "mesh")),
|
||||
Sound: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "sound")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,7 @@ type Model struct {
|
||||
peerMesh *mesh.State
|
||||
meshTickActive bool
|
||||
feedMeshVisible bool
|
||||
sound *sound.Engine
|
||||
}
|
||||
|
||||
func NewModel(cfg config.Config) Model {
|
||||
@@ -179,6 +183,7 @@ func NewModel(cfg config.Config) Model {
|
||||
pm := mesh.NewState()
|
||||
pm.SetReducedMotion(cfg.NoColor || envTruthy("HP_VIZ_REDUCED_MOTION"))
|
||||
m.peerMesh = pm
|
||||
m.sound = sound.New()
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -261,9 +266,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Mouse):
|
||||
return m.toggleMouse()
|
||||
case key.Matches(msg, m.keys.Sound):
|
||||
m.sound.Toggle()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.sse.Stop()
|
||||
return m, tea.Quit
|
||||
return m, m.quit()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -284,9 +291,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Mouse):
|
||||
return m.toggleMouse()
|
||||
case key.Matches(msg, m.keys.Sound):
|
||||
m.sound.Toggle()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.sse.Stop()
|
||||
return m, tea.Quit
|
||||
return m, m.quit()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -294,8 +303,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.keys.Mouse):
|
||||
return m.toggleMouse()
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.sse.Stop()
|
||||
return m, tea.Quit
|
||||
return m, m.quit()
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.showHelp = !m.showHelp
|
||||
case key.Matches(msg, m.keys.Tab):
|
||||
@@ -324,6 +332,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.meshTickActive = false
|
||||
}
|
||||
}
|
||||
case key.Matches(msg, m.keys.Sound):
|
||||
m.sound.Toggle()
|
||||
case key.Matches(msg, m.keys.Filter):
|
||||
m.cancelSearch()
|
||||
m.filtering = true
|
||||
@@ -577,6 +587,7 @@ func (m *Model) handleSSE(evt transport.Event) {
|
||||
fmt.Print("\a")
|
||||
}
|
||||
m.peerMesh.TriggerAttack(a.PeerID, a.Service, m.meshPalette())
|
||||
m.sound.PlayAttack()
|
||||
case "peer":
|
||||
p := data.(viz.PeerEvent)
|
||||
m.store.Push(viz.FeedFromPeer(p))
|
||||
@@ -589,6 +600,11 @@ func (m *Model) handleSSE(evt transport.Event) {
|
||||
net := viz.ApplyPeerEvent(m.network, p)
|
||||
m.network = net
|
||||
m.syncMeshFromNetwork()
|
||||
if p.Status == "online" {
|
||||
m.sound.PlayPeerOnline()
|
||||
} else if p.Status == "offline" {
|
||||
m.sound.PlayPeerOffline()
|
||||
}
|
||||
case "block":
|
||||
b := data.(viz.BlockEvent)
|
||||
m.store.Push(viz.FeedFromBlock(b))
|
||||
@@ -596,6 +612,7 @@ func (m *Model) handleSSE(evt transport.Event) {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.peerMesh.TriggerBlock(m.meshPalette())
|
||||
m.sound.PlayBlock()
|
||||
case "moderation":
|
||||
mod := data.(viz.ModerationEvent)
|
||||
item := viz.FeedFromModeration(mod)
|
||||
@@ -609,6 +626,7 @@ func (m *Model) handleSSE(evt transport.Event) {
|
||||
if mod.TierAfter == "block" && mod.TierBefore != "block" {
|
||||
m.peerMesh.TriggerBlock(m.meshPalette())
|
||||
}
|
||||
m.sound.PlayModeration()
|
||||
case "stats":
|
||||
s := data.(viz.Stats)
|
||||
m.stats = &s
|
||||
@@ -685,6 +703,12 @@ func (m *Model) openDetail() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) quit() tea.Cmd {
|
||||
m.sse.Stop()
|
||||
m.sound.Close()
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
func (m Model) toggleMouse() (Model, tea.Cmd) {
|
||||
if m.mouseEnabled {
|
||||
m.mouseEnabled = false
|
||||
|
||||
Reference in New Issue
Block a user