diff --git a/go.mod b/go.mod index eb49e49..0838658 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.svabi.ch/jan/roadamp go 1.25.0 require ( + github.com/gorilla/websocket v1.5.3 // indirect golang.org/x/sys v0.45.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c9ff7fe..c51a036 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/server/hub.go b/internal/server/hub.go new file mode 100644 index 0000000..a7e075a --- /dev/null +++ b/internal/server/hub.go @@ -0,0 +1,120 @@ +//go:build windows + +package server + +import ( + "log" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = pongWait * 9 / 10 + maxMessageSize = 512 +) + +// hub maintains the set of active WebSocket clients and broadcasts messages. +type hub struct { + clients map[*wsClient]struct{} + broadcast chan []byte + register chan *wsClient + unregister chan *wsClient + onCmd func([]byte) // called for each message from any client +} + +func newHub(onCmd func([]byte)) *hub { + return &hub{ + clients: make(map[*wsClient]struct{}), + broadcast: make(chan []byte, 64), + register: make(chan *wsClient, 8), + unregister: make(chan *wsClient, 8), + onCmd: onCmd, + } +} + +func (h *hub) run() { + for { + select { + case c := <-h.register: + h.clients[c] = struct{}{} + case c := <-h.unregister: + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.send) + } + case msg := <-h.broadcast: + for c := range h.clients { + select { + case c.send <- msg: + default: + // Slow client — drop and disconnect. + close(c.send) + delete(h.clients, c) + } + } + } + } +} + +// ── wsClient ────────────────────────────────────────────────────────────────── + +type wsClient struct { + hub *hub + conn *websocket.Conn + send chan []byte +} + +func (c *wsClient) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, msg, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, + websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("ws read: %v", err) + } + break + } + if c.hub.onCmd != nil { + c.hub.onCmd(msg) + } + } +} + +func (c *wsClient) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case msg, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4635b73..e12d372 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,8 +1,11 @@ -// Package server wires together the HTTP API, the Winamp controller, -// killist, resume, and the embedded web frontend. +//go:build windows + +// Package server wires together the HTTP API, WebSocket hub, Winamp controller, +// WASAPI viz capture, killist, and resume. package server import ( + "context" "encoding/json" "fmt" "log" @@ -15,24 +18,27 @@ import ( "git.svabi.ch/jan/roadamp/internal/killist" "git.svabi.ch/jan/roadamp/internal/resume" "git.svabi.ch/jan/roadamp/internal/volume" + "git.svabi.ch/jan/roadamp/internal/viz" "git.svabi.ch/jan/roadamp/internal/winamp" + "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) -// Config holds runtime configuration loaded from config.yaml. +// ── Config ──────────────────────────────────────────────────────────────────── + type Config struct { - Port int `yaml:"port"` - WinampPath string `yaml:"winamp_path"` + Port int `yaml:"port"` + WinampPath string `yaml:"winamp_path"` KillListFile string `yaml:"killist_file"` - ResumeFile string `yaml:"resume_file"` + ResumeFile string `yaml:"resume_file"` } func loadConfig(path string) (Config, error) { cfg := Config{ - Port: 8080, - WinampPath: `C:\Program Files\Winamp\Winamp.exe`, + Port: 8080, + WinampPath: `C:\Program Files\Winamp\Winamp.exe`, KillListFile: "killist.dat", - ResumeFile: "resume.dat", + ResumeFile: "resume.dat", } data, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -44,12 +50,14 @@ func loadConfig(path string) (Config, error) { return cfg, yaml.Unmarshal(data, &cfg) } -// Server is the roadamp HTTP server. +// ── Server ──────────────────────────────────────────────────────────────────── + type Server struct { - cfg Config - wa *winamp.Controller - kl *killist.KillList - mux *http.ServeMux + cfg Config + wa *winamp.Controller + kl *killist.KillList + hub *hub + mux *http.ServeMux } func New(configPath string) (*Server, error) { @@ -61,21 +69,17 @@ func New(configPath string) (*Server, error) { if err != nil { return nil, fmt.Errorf("killist: %w", err) } - - s := &Server{ - cfg: cfg, - wa: winamp.New(), - kl: kl, - mux: http.NewServeMux(), - } + s := &Server{cfg: cfg, wa: winamp.New(), kl: kl, mux: http.NewServeMux()} + s.hub = newHub(s.handleCommand) s.routes() return s, nil } func (s *Server) Run() error { - // Attempt to restore resume state on startup. + go s.hub.run() + go s.broadcastStatus() + go s.broadcastViz() go s.restoreResume() - // Background killist checker. go s.killChecker() addr := fmt.Sprintf(":%d", s.cfg.Port) @@ -83,29 +87,161 @@ func (s *Server) Run() error { return http.ListenAndServe(addr, s.mux) } -// ── Routes ────────────────────────────────────────────────────────────────── +// ── Routes ──────────────────────────────────────────────────────────────────── func (s *Server) routes() { - // Static frontend s.mux.Handle("/", http.FileServer(http.Dir("web/static"))) - // API + // WebSocket (primary interface) + s.mux.HandleFunc("/ws", s.handleWS) + + // REST API (kept for curl/debugging) s.mux.HandleFunc("/api/status", s.handleStatus) s.mux.HandleFunc("/api/play", s.handlePlay) s.mux.HandleFunc("/api/pause", s.handlePause) s.mux.HandleFunc("/api/stop", s.handleStop) s.mux.HandleFunc("/api/next", s.handleNext) s.mux.HandleFunc("/api/prev", s.handlePrev) - s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds) - s.mux.HandleFunc("/api/volume", s.handleVolume) // GET | POST ?level=0-100 - s.mux.HandleFunc("/api/mute", s.handleMute) // GET | POST ?muted=true|false + s.mux.HandleFunc("/api/seek", s.handleSeek) + s.mux.HandleFunc("/api/volume", s.handleVolume) + s.mux.HandleFunc("/api/mute", s.handleMute) s.mux.HandleFunc("/api/killist", s.handleKillist) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } -// ── Handlers ───────────────────────────────────────────────────────────────── +// ── WebSocket ───────────────────────────────────────────────────────────────── + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("ws upgrade: %v", err) + return + } + c := &wsClient{hub: s.hub, conn: conn, send: make(chan []byte, 32)} + s.hub.register <- c + + // Send current status immediately upon connect. + if msg, err := s.statusMsg(); err == nil { + c.send <- msg + } + + go c.writePump() + go c.readPump() +} + +// ── WebSocket broadcast workers ─────────────────────────────────────────────── + +// broadcastStatus pushes a status message to all clients every 500 ms. +func (s *Server) broadcastStatus() { + for range time.Tick(500 * time.Millisecond) { + msg, err := s.statusMsg() + if err != nil { + continue + } + select { + case s.hub.broadcast <- msg: + default: + } + } +} + +// broadcastViz starts the WASAPI loopback capturer and forwards spectrum +// frames to all WebSocket clients at ~30 fps. +func (s *Server) broadcastViz() { + cap := viz.NewCapturer() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go cap.Start(ctx) -type statusResponse struct { + for bars := range cap.C { + data, err := json.Marshal(struct { + Type string `json:"type"` + Bars []float32 `json:"bars"` + }{"viz", bars}) + if err != nil { + continue + } + select { + case s.hub.broadcast <- data: + default: + } + } +} + +// ── Command dispatcher (WebSocket) ──────────────────────────────────────────── + +type wsCommand struct { + Cmd string `json:"cmd"` + Delta int `json:"delta"` + Level int `json:"level"` + Muted bool `json:"muted"` + Title string `json:"title"` +} + +func (s *Server) handleCommand(raw []byte) { + var cmd wsCommand + if err := json.Unmarshal(raw, &cmd); err != nil { + return + } + switch cmd.Cmd { + case "play": + s.wa.Play() + case "pause": + s.wa.Pause() + case "stop": + if s.wa.IsPaused() { + _ = resume.Save(s.cfg.ResumeFile, resume.State{ + PlaylistLength: s.wa.GetPlaylistLength(), + PlaylistPos: s.wa.GetPlaylistPosition(), + OffsetSeconds: s.wa.GetPosition(), + TrackTitle: s.wa.GetTitle(), + }) + } + s.wa.Stop() + case "next": + s.wa.NextTrack() + case "prev": + s.wa.PrevTrack() + case "seek": + pos := s.wa.GetPosition() + cmd.Delta + if pos < 0 { + pos = 0 + } + s.wa.Seek(pos) + case "volume": + _ = volume.Set(cmd.Level) + case "mute": + _ = volume.SetMute(cmd.Muted) + case "killist_add": + if title := s.wa.GetTitle(); title != "" { + _ = s.kl.Add(title) + } + case "killist_remove": + _ = s.kl.Remove(cmd.Title) + case "winamp_start": + if !s.wa.IsRunning() { + _ = exec.Command(s.cfg.WinampPath).Start() + } + } + // Push a fresh status after any command. + if msg, err := s.statusMsg(); err == nil { + select { + case s.hub.broadcast <- msg: + default: + } + } +} + +// ── Shared status builder ───────────────────────────────────────────────────── + +type statusMsg struct { + Type string `json:"type"` Running bool `json:"running"` State string `json:"state"` Title string `json:"title"` @@ -114,50 +250,92 @@ type statusResponse struct { PlaylistPos int `json:"playlist_pos"` PlaylistLength int `json:"playlist_length"` Version string `json:"version"` - Volume int `json:"volume"` // system master volume 0–100 + Volume int `json:"volume"` Muted bool `json:"muted"` } -func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - resp := statusResponse{Running: s.wa.IsRunning()} - if resp.Running { +func (s *Server) statusMsg() ([]byte, error) { + msg := statusMsg{Type: "status", Running: s.wa.IsRunning()} + if msg.Running { switch s.wa.PlayState() { case 1: - resp.State = "playing" + msg.State = "playing" case 3: - resp.State = "paused" + msg.State = "paused" default: - resp.State = "stopped" + msg.State = "stopped" } - resp.Title = s.wa.GetTitle() - resp.Position = s.wa.GetPosition() - resp.Length = s.wa.GetLength() - resp.PlaylistPos = s.wa.GetPlaylistPosition() - resp.PlaylistLength = s.wa.GetPlaylistLength() - resp.Version = s.wa.GetVersion() + msg.Title = s.wa.GetTitle() + msg.Position = s.wa.GetPosition() + msg.Length = s.wa.GetLength() + msg.PlaylistPos = s.wa.GetPlaylistPosition() + msg.PlaylistLength = s.wa.GetPlaylistLength() + msg.Version = s.wa.GetVersion() } - // System volume is available regardless of whether Winamp is running. if vol, err := volume.Get(); err == nil { - resp.Volume = vol + msg.Volume = vol } if muted, err := volume.GetMute(); err == nil { - resp.Muted = muted + msg.Muted = muted } - jsonOK(w, resp) + return json.Marshal(msg) } -func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { - ok := s.wa.Play() - jsonOK(w, map[string]bool{"ok": ok}) +// ── Background workers ──────────────────────────────────────────────────────── + +func (s *Server) killChecker() { + for range time.Tick(2 * time.Second) { + if !s.wa.IsPlaying() { + continue + } + if title := s.wa.GetTitle(); s.kl.Contains(title) { + log.Printf("killist: skipping %q", title) + s.wa.NextTrack() + } + } } -func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { - ok := s.wa.Pause() - jsonOK(w, map[string]bool{"ok": ok}) +func (s *Server) restoreResume() { + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if s.wa.IsRunning() { + break + } + time.Sleep(500 * time.Millisecond) + } + st, err := resume.Load(s.cfg.ResumeFile) + if err != nil || st == nil { + return + } + if s.wa.GetPlaylistLength() != st.PlaylistLength { + _ = resume.Delete(s.cfg.ResumeFile) + return + } + s.wa.Play() + s.wa.Seek(st.OffsetSeconds) + s.wa.Pause() + _ = resume.Delete(s.cfg.ResumeFile) + log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds) } +// ── REST handlers (for curl/debug) ─────────────────────────────────────────── + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + msg, err := s.statusMsg() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(msg) +} + +func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) } +func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) } +func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) } +func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) } + func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { - // Save resume state before stopping. if s.wa.IsPaused() { _ = resume.Save(s.cfg.ResumeFile, resume.State{ PlaylistLength: s.wa.GetPlaylistLength(), @@ -166,18 +344,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { TrackTitle: s.wa.GetTitle(), }) } - ok := s.wa.Stop() - jsonOK(w, map[string]bool{"ok": ok}) -} - -func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { - ok := s.wa.NextTrack() - jsonOK(w, map[string]bool{"ok": ok}) -} - -func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { - ok := s.wa.PrevTrack() - jsonOK(w, map[string]bool{"ok": ok}) + jsonOK(w, s.wa.Stop()) } func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { @@ -190,48 +357,33 @@ func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { if pos < 0 { pos = 0 } - ok := s.wa.Seek(pos) - jsonOK(w, map[string]bool{"ok": ok}) + jsonOK(w, s.wa.Seek(pos)) } func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - vol, err := volume.Get() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + vol, _ := volume.Get() jsonOK(w, map[string]int{"volume": vol}) return } lvl, err := strconv.Atoi(r.URL.Query().Get("level")) if err != nil { - http.Error(w, "invalid level (0–100)", http.StatusBadRequest) - return - } - if err := volume.Set(lvl); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "invalid level", http.StatusBadRequest) return } + _ = volume.Set(lvl) jsonOK(w, map[string]int{"volume": lvl}) } func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - muted, err := volume.GetMute() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + muted, _ := volume.GetMute() jsonOK(w, map[string]bool{"muted": muted}) return } val := r.URL.Query().Get("muted") muted := val == "true" || val == "1" - if err := volume.SetMute(muted); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + _ = volume.SetMute(muted) jsonOK(w, map[string]bool{"muted": muted}) } @@ -245,20 +397,11 @@ func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { http.Error(w, "no track playing", http.StatusConflict) return } - if err := s.kl.Add(title); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + _ = s.kl.Add(title) jsonOK(w, map[string]string{"added": title}) case http.MethodDelete: - title := r.URL.Query().Get("title") - if err := s.kl.Remove(title); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - jsonOK(w, map[string]string{"removed": title}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + _ = s.kl.Remove(r.URL.Query().Get("title")) + jsonOK(w, map[string]bool{"ok": true}) } } @@ -267,60 +410,13 @@ func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]string{"status": "already_running"}) return } - cmd := exec.Command(s.cfg.WinampPath) - if err := cmd.Start(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if err := exec.Command(s.cfg.WinampPath).Start(); err != nil { + http.Error(w, err.Error(), 500) return } jsonOK(w, map[string]string{"status": "started"}) } -// ── Background workers ─────────────────────────────────────────────────────── - -func (s *Server) killChecker() { - for range time.Tick(2 * time.Second) { - if !s.wa.IsPlaying() { - continue - } - title := s.wa.GetTitle() - if s.kl.Contains(title) { - log.Printf("killist: skipping %q", title) - s.wa.NextTrack() - } - } -} - -func (s *Server) restoreResume() { - // Wait a moment for Winamp to start up. - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { - if s.wa.IsRunning() { - break - } - time.Sleep(500 * time.Millisecond) - } - if !s.wa.IsRunning() { - return - } - - st, err := resume.Load(s.cfg.ResumeFile) - if err != nil || st == nil { - return - } - // Only restore if playlist length still matches (same session). - if s.wa.GetPlaylistLength() != st.PlaylistLength { - _ = resume.Delete(s.cfg.ResumeFile) - return - } - s.wa.Play() - s.wa.Seek(st.OffsetSeconds) - s.wa.Pause() - _ = resume.Delete(s.cfg.ResumeFile) - log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/internal/viz/capture.go b/internal/viz/capture.go new file mode 100644 index 0000000..a0c9870 --- /dev/null +++ b/internal/viz/capture.go @@ -0,0 +1,405 @@ +//go:build windows + +// Package viz captures the Windows audio loopback via WASAPI and emits +// FFT spectrum data for visualisation in the web frontend. +package viz + +import ( + "context" + "fmt" + "log" + "math" + "math/cmplx" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// NumBars is the number of frequency bars emitted per frame. +const NumBars = 64 + +const ( + fftN = 2048 // FFT window size (power of 2) + + // WASAPI + audclntShareModeShared = 0 + audclntStreamFlagsLoopback = 0x00020000 + audclntBufferFlagsSilent = 0x2 + bufDuration = 1_000_000 // 100 ms in 100-ns units + + // Wave format tags + waveFormatPCM = 1 + waveFormatFloat = 3 + waveFormatExtensibleTag = 0xFFFE +) + +// ── GUIDs ───────────────────────────────────────────────────────────────────── + +var ( + clsidMMDeviceEnumerator = windows.GUID{ + Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, + Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}, + } + iidIMMDeviceEnumerator = windows.GUID{ + Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, + Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}, + } + iidIAudioClient = windows.GUID{ + Data1: 0x1CB9AD4C, Data2: 0xDBFA, Data3: 0x4c32, + Data4: [8]byte{0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2}, + } + iidIAudioCaptureClient = windows.GUID{ + Data1: 0xC8ADBD64, Data2: 0xE71E, Data3: 0x48a0, + Data4: [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}, + } + subFormatFloat = windows.GUID{ + Data1: 0x00000003, Data2: 0x0000, Data3: 0x0010, + Data4: [8]byte{0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71}, + } +) + +// ── WAVEFORMAT structs ──────────────────────────────────────────────────────── + +type waveFormatEx struct { + FormatTag uint16 + Channels uint16 + SamplesPerSec uint32 + AvgBytesPerSec uint32 + BlockAlign uint16 + BitsPerSample uint16 + Size uint16 +} + +type waveFormatExtensibleEx struct { + Format waveFormatEx + Samples uint16 + ChannelMask uint32 + SubFormat windows.GUID +} + +// ── DLL procs ───────────────────────────────────────────────────────────────── + +var ( + ole32 = windows.NewLazySystemDLL("ole32.dll") + coInitializeEx = ole32.NewProc("CoInitializeEx") + coUninitialize = ole32.NewProc("CoUninitialize") + coCreateInstance = ole32.NewProc("CoCreateInstance") + coTaskMemFree = ole32.NewProc("CoTaskMemFree") +) + +// ── COM vtable helpers ──────────────────────────────────────────────────────── + +var ptrSize = unsafe.Sizeof(uintptr(0)) + +func procAt(comObj uintptr, methodIdx int) uintptr { + vtbl := *(*uintptr)(unsafe.Pointer(comObj)) + return *(*uintptr)(unsafe.Pointer(vtbl + uintptr(methodIdx)*ptrSize)) +} + +func comRelease(p uintptr) { + if p != 0 { + syscall.Syscall(procAt(p, 2), 1, p, 0, 0) + } +} + +// ── Capturer ────────────────────────────────────────────────────────────────── + +// Capturer streams FFT spectrum bars from the system audio loopback. +type Capturer struct { + // C receives slices of NumBars float32 values in [0.0, 1.0] at ~30 fps. + // Slow consumers cause frames to be dropped (non-blocking send). + C chan []float32 +} + +// NewCapturer creates a Capturer ready to Start. +func NewCapturer() *Capturer { + return &Capturer{C: make(chan []float32, 4)} +} + +// Start begins the capture loop; blocks until ctx is cancelled. +// Errors are logged but never fatal — the channel simply stays empty. +func (c *Capturer) Start(ctx context.Context) { + if err := c.run(ctx); err != nil { + log.Printf("viz: %v", err) + } +} + +func (c *Capturer) run(ctx context.Context) error { + coInitializeEx.Call(0, 0) // COINIT_MULTITHREADED + defer coUninitialize.Call() + + // ── IMMDeviceEnumerator ────────────────────────────────────────────────── + var enumerator uintptr + if hr, _, _ := coCreateInstance.Call( + uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)), 0, 0x17, + uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)), + uintptr(unsafe.Pointer(&enumerator)), + ); hr != 0 { + return fmt.Errorf("CoCreateInstance(MMDeviceEnumerator): 0x%08X", hr) + } + defer comRelease(enumerator) + + // ── Default render device ──────────────────────────────────────────────── + // GetDefaultAudioEndpoint(eRender, eConsole, &device) — vtable index 4, 4 args + var device uintptr + if hr, _, _ := syscall.Syscall6( + procAt(enumerator, 4), 4, + enumerator, 0, 0, uintptr(unsafe.Pointer(&device)), 0, 0, + ); hr != 0 { + return fmt.Errorf("GetDefaultAudioEndpoint: 0x%08X", hr) + } + defer comRelease(device) + + // ── IAudioClient ──────────────────────────────────────────────────────── + // IMMDevice::Activate(riid, clsCtx, pParams, &ppv) — vtable index 3, 5 args + var ac uintptr + if hr, _, _ := syscall.Syscall6( + procAt(device, 3), 5, + device, uintptr(unsafe.Pointer(&iidIAudioClient)), 0x17, 0, + uintptr(unsafe.Pointer(&ac)), 0, + ); hr != 0 { + return fmt.Errorf("Activate(IAudioClient): 0x%08X", hr) + } + defer comRelease(ac) + + // ── Mix format ────────────────────────────────────────────────────────── + var fmtPtr uintptr + if hr, _, _ := syscall.Syscall( + procAt(ac, 8), 2, // GetMixFormat + ac, uintptr(unsafe.Pointer(&fmtPtr)), 0, + ); hr != 0 { + return fmt.Errorf("GetMixFormat: 0x%08X", hr) + } + defer coTaskMemFree.Call(fmtPtr) + + wfx := (*waveFormatEx)(unsafe.Pointer(fmtPtr)) + sampleRate := int(wfx.SamplesPerSec) + channels := int(wfx.Channels) + isFloat := wfx.FormatTag == waveFormatFloat + if wfx.FormatTag == waveFormatExtensibleTag && wfx.Size >= 22 { + ext := (*waveFormatExtensibleEx)(unsafe.Pointer(fmtPtr)) + isFloat = ext.SubFormat == subFormatFloat + } + log.Printf("viz: loopback format %d Hz, %d ch, %d bit, float=%v", + sampleRate, channels, wfx.BitsPerSample, isFloat) + + if !isFloat || wfx.BitsPerSample != 32 { + return fmt.Errorf("viz: unsupported format (need float32); got tag=%04X bits=%d", + wfx.FormatTag, wfx.BitsPerSample) + } + + // ── Initialize loopback ────────────────────────────────────────────────── + if hr, _, _ := syscall.Syscall9( + procAt(ac, 3), 7, // IAudioClient::Initialize + ac, + audclntShareModeShared, + audclntStreamFlagsLoopback, + uintptr(bufDuration), 0, // hnsBufferDuration, hnsPeriodicity + fmtPtr, 0, // pFormat, AudioSessionGuid + 0, 0, + ); hr != 0 { + return fmt.Errorf("IAudioClient::Initialize: 0x%08X", hr) + } + + // ── IAudioCaptureClient ────────────────────────────────────────────────── + var acc uintptr + if hr, _, _ := syscall.Syscall( + procAt(ac, 14), 3, // GetService + ac, + uintptr(unsafe.Pointer(&iidIAudioCaptureClient)), + uintptr(unsafe.Pointer(&acc)), + ); hr != 0 { + return fmt.Errorf("GetService(IAudioCaptureClient): 0x%08X", hr) + } + defer comRelease(acc) + + // ── Start ──────────────────────────────────────────────────────────────── + if hr, _, _ := syscall.Syscall(procAt(ac, 10), 1, ac, 0, 0); hr != 0 { + return fmt.Errorf("IAudioClient::Start: 0x%08X", hr) + } + defer syscall.Syscall(procAt(ac, 11), 1, ac, 0, 0) // Stop + + // ── Capture loop ───────────────────────────────────────────────────────── + buf := make([]float64, 0, fftN*2) + smooth := make([]float32, NumBars) + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-tick.C: + buf = drainLoopback(acc, channels, buf) + for len(buf) >= fftN { + bars := spectrum(buf[:fftN], sampleRate, smooth) + copy(smooth, bars) + select { + case c.C <- bars: + default: + } + buf = buf[fftN:] + } + } + } +} + +// drainLoopback reads all pending audio frames into buf and returns it. +func drainLoopback(acc uintptr, channels int, buf []float64) []float64 { + for { + // GetNextPacketSize + var packetFrames uint32 + if hr, _, _ := syscall.Syscall( + procAt(acc, 5), 2, + acc, uintptr(unsafe.Pointer(&packetFrames)), 0, + ); hr != 0 || packetFrames == 0 { + break + } + + // GetBuffer(ppData, &numFrames, &flags, NULL, NULL) — 6 args + var dataPtr uintptr + var numFrames uint32 + var flags uint32 + if hr, _, _ := syscall.Syscall6( + procAt(acc, 3), 6, + acc, + uintptr(unsafe.Pointer(&dataPtr)), + uintptr(unsafe.Pointer(&numFrames)), + uintptr(unsafe.Pointer(&flags)), + 0, 0, + ); hr != 0 { + break + } + + if flags&audclntBufferFlagsSilent == 0 && dataPtr != 0 && numFrames > 0 { + samples := unsafe.Slice((*float32)(unsafe.Pointer(dataPtr)), int(numFrames)*channels) + for i := 0; i < int(numFrames); i++ { + var mono float64 + for ch := 0; ch < channels; ch++ { + mono += float64(samples[i*channels+ch]) + } + buf = append(buf, mono/float64(channels)) + } + } + + // ReleaseBuffer + syscall.Syscall(procAt(acc, 4), 2, acc, uintptr(numFrames), 0) + } + return buf +} + +// ── Spectrum analysis ───────────────────────────────────────────────────────── + +// spectrum applies a Hanning window, runs the FFT, maps to NumBars +// log-spaced frequency bins, and applies fast-attack/slow-decay smoothing. +func spectrum(samples []float64, sampleRate int, prev []float32) []float32 { + n := len(samples) + + // Hanning window + cx := make([]complex128, n) + for i, s := range samples { + w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1))) + cx[i] = complex(s*w, 0) + } + + ditFFT(cx) + + // Magnitude of positive frequencies, normalised + bins := make([]float64, n/2) + scale := 2.0 / float64(n) + for i := range bins { + bins[i] = cmplx.Abs(cx[i]) * scale + } + + // Log-spaced output bars: 40 Hz → 20 kHz + const fMin, fMax = 40.0, 20_000.0 + freqRes := float64(sampleRate) / float64(n) + bars := make([]float32, NumBars) + + for b := 0; b < NumBars; b++ { + t := float64(b) / float64(NumBars-1) + f := fMin * math.Pow(fMax/fMin, t) + + var fNext float64 + if b < NumBars-1 { + t2 := float64(b+1) / float64(NumBars-1) + fNext = fMin * math.Pow(fMax/fMin, t2) + } else { + fNext = fMax + } + + lo := clamp(int(f/freqRes), 0, len(bins)-1) + hi := clamp(int(fNext/freqRes), lo+1, len(bins)) + + var sum float64 + for i := lo; i < hi; i++ { + sum += bins[i] + } + avg := sum / float64(hi-lo) + + // dB → [0, 1] + dB := 20 * math.Log10(avg+1e-9) + norm := float32((dB + 80) / 80) + if norm < 0 { + norm = 0 + } + if norm > 1 { + norm = 1 + } + + // Fast attack, slow decay + if norm > prev[b] { + bars[b] = norm + } else { + bars[b] = prev[b] * 0.88 + } + } + return bars +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// ── Cooley-Tukey FFT ───────────────────────────────────────────────────────── + +// ditFFT is an in-place, decimation-in-time FFT. len(x) must be a power of 2. +func ditFFT(x []complex128) { + n := len(x) + // Bit-reversal permutation + j := 0 + for i := 1; i < n; i++ { + bit := n >> 1 + for j&bit != 0 { + j ^= bit + bit >>= 1 + } + j ^= bit + if i < j { + x[i], x[j] = x[j], x[i] + } + } + // Butterfly stages + for length := 2; length <= n; length <<= 1 { + half := length >> 1 + wStep := cmplx.Exp(complex(0, -math.Pi/float64(half))) + for i := 0; i < n; i += length { + w := complex(1, 0) + for k := 0; k < half; k++ { + u := x[i+k] + v := x[i+k+half] * w + x[i+k] = u + v + x[i+k+half] = u - v + w *= wStep + } + } + } +} diff --git a/web/static/app.js b/web/static/app.js index f535b39..b8d420f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1,14 +1,8 @@ 'use strict'; -const api = (path, opts = {}) => - fetch(path, opts).then(r => r.json()).catch(() => null); - -// ── State ───────────────────────────────────────────────────────────────────── -let currentVolume = 50; // 0–100 (Windows system volume) -let pollTimer = null; - // ── DOM refs ────────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); + const statusDot = $('winamp-status'); const stateLabel = $('state-label'); const trackTitle = $('track-title'); @@ -22,167 +16,231 @@ const btnMute = $('btn-mute'); const btnPlay = $('btn-play'); const killistPanel = $('killist-panel'); const killistItems = $('killist-items'); +const canvas = $('viz'); +const ctx2d = canvas.getContext('2d'); + +// ── State ───────────────────────────────────────────────────────────────────── +let currentVolume = 50; +let ws = null; +let reconnectTimer = null; + +// Viz state +const NUM_BARS = 64; +const peaks = new Float32Array(NUM_BARS); +let lastBars = new Float32Array(NUM_BARS); +let rafId = null; + +// ── WebSocket ───────────────────────────────────────────────────────────────── +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws`); + + ws.addEventListener('open', () => { + statusDot.className = 'ok'; + stateLabel.textContent = 'Verbunden'; + clearTimeout(reconnectTimer); + }); + + ws.addEventListener('close', () => { + statusDot.className = 'err'; + stateLabel.textContent = 'Verbindung unterbrochen…'; + ws = null; + reconnectTimer = setTimeout(connect, 3000); + }); + + ws.addEventListener('error', () => ws.close()); + + ws.addEventListener('message', e => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + + if (msg.type === 'status') applyStatus(msg); + if (msg.type === 'viz') applyViz(msg.bars); + }); +} -// ── Playback controls ───────────────────────────────────────────────────────── -btnPlay.addEventListener('click', async () => { - const st = await api('/api/status'); - if (!st) return; - if (st.state === 'playing') { - await api('/api/pause', { method: 'POST' }); +function send(obj) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + } +} + +// ── Status handler ──────────────────────────────────────────────────────────── +function applyStatus(st) { + if (!st.running) { + statusDot.className = 'err'; + stateLabel.textContent = 'Winamp nicht gestartet'; + trackTitle.textContent = '–'; + playlistPos.textContent = ''; + return; + } + + statusDot.className = 'ok'; + const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; + stateLabel.textContent = stateMap[st.state] ?? st.state; + + trackTitle.textContent = st.title || '–'; + playlistPos.textContent = st.playlist_length + ? `${st.playlist_pos} / ${st.playlist_length}` : ''; + + if (st.length > 0) { + progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; + timeCurrent.textContent = fmtTime(st.position); + timeLength.textContent = fmtTime(st.length); } else { - await api('/api/play', { method: 'POST' }); + progressFill.style.width = '0%'; + timeCurrent.textContent = '0:00'; + timeLength.textContent = '0:00'; } - poll(); -}); -$('btn-stop').addEventListener('click', async () => { - await api('/api/stop', { method: 'POST' }); poll(); -}); -$('btn-next').addEventListener('click', async () => { - await api('/api/next', { method: 'POST' }); poll(); -}); -$('btn-prev').addEventListener('click', async () => { - await api('/api/prev', { method: 'POST' }); poll(); + btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; + + if (typeof st.volume === 'number') { + currentVolume = st.volume; + updateVolumeFill(st.muted); + updateMuteBtn(st.muted); + } +} + +// ── Controls ────────────────────────────────────────────────────────────────── +btnPlay.addEventListener('click', () => { + // Optimistic toggle — server will push the real state back immediately. + const playing = btnPlay.textContent === '⏸'; + send({ cmd: playing ? 'pause' : 'play' }); }); -// ── Seek buttons ────────────────────────────────────────────────────────────── +$('btn-stop').addEventListener('click', () => send({ cmd: 'stop' })); +$('btn-next').addEventListener('click', () => send({ cmd: 'next' })); +$('btn-prev').addEventListener('click', () => send({ cmd: 'prev' })); + document.querySelectorAll('.btn-seek').forEach(btn => { - btn.addEventListener('click', async () => { - const delta = parseInt(btn.dataset.delta, 10); - await api(`/api/seek?delta=${delta}`, { method: 'POST' }); - poll(); - }); + btn.addEventListener('click', () => + send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) })); }); -// ── Progress bar click-to-seek ──────────────────────────────────────────────── $('progress-bar').addEventListener('click', async e => { + // We need current length — read from last status (stored in DOM for now via timeLength). + const total = parseTime(timeLength.textContent); + if (!total) return; const rect = e.currentTarget.getBoundingClientRect(); - const frac = (e.clientX - rect.left) / rect.width; - const st = await api('/api/status'); - if (!st || !st.length) return; - const target = Math.round(frac * st.length); - const delta = target - st.position; - await api(`/api/seek?delta=${delta}`, { method: 'POST' }); - poll(); + const target = Math.round((e.clientX - rect.left) / rect.width * total); + const current = parseTime(timeCurrent.textContent); + send({ cmd: 'seek', delta: target - current }); }); // ── Volume ──────────────────────────────────────────────────────────────────── -$('btn-vol-up').addEventListener('click', async () => { +$('btn-vol-up').addEventListener('click', () => { currentVolume = Math.min(100, currentVolume + 5); - await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); + send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); -$('btn-vol-down').addEventListener('click', async () => { +$('btn-vol-down').addEventListener('click', () => { currentVolume = Math.max(0, currentVolume - 5); - await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); + send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); -$('volume-bar').addEventListener('click', async e => { +$('volume-bar').addEventListener('click', e => { const rect = e.currentTarget.getBoundingClientRect(); currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); - await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); + send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); -btnMute.addEventListener('click', async () => { - const cur = await api('/api/mute'); - const newMuted = !(cur?.muted); - await api(`/api/mute?muted=${newMuted}`, { method: 'POST' }); - updateMuteBtn(newMuted); +btnMute.addEventListener('click', () => { + const nowMuted = btnMute.classList.contains('muted'); + send({ cmd: 'mute', muted: !nowMuted }); + updateMuteBtn(!nowMuted); + updateVolumeFill(!nowMuted); }); -function updateVolumeFill(muted = false) { - volumeFill.style.width = currentVolume + '%'; - volumePct.textContent = currentVolume + ' %'; +function updateVolumeFill(muted = btnMute.classList.contains('muted')) { + volumeFill.style.width = currentVolume + '%'; + volumePct.textContent = currentVolume + ' %'; volumeFill.classList.toggle('muted', muted); } function updateMuteBtn(muted) { btnMute.textContent = muted ? '🔇' : '🔊'; btnMute.classList.toggle('muted', muted); - volumeFill.classList.toggle('muted', muted); } // ── KillList ────────────────────────────────────────────────────────────────── -$('btn-kill').addEventListener('click', async () => { - const res = await api('/api/killist', { method: 'POST' }); - if (res?.added) { - showToast(`🚫 ${res.added}`); - } +$('btn-kill').addEventListener('click', () => { + send({ cmd: 'killist_add' }); + showToast('🚫 Track zur Skip-Liste hinzugefügt'); }); + $('btn-show-killist').addEventListener('click', async () => { - await refreshKillist(); - killistPanel.classList.remove('hidden'); -}); -$('btn-close-killist').addEventListener('click', () => { - killistPanel.classList.add('hidden'); -}); -async function refreshKillist() { - const list = await api('/api/killist'); - if (!list) return; + const list = await fetch('/api/killist').then(r => r.json()).catch(() => []); killistItems.innerHTML = ''; - list.forEach(title => { - const li = document.createElement('li'); + (list || []).forEach(title => { + const li = document.createElement('li'); li.innerHTML = `${escHtml(title)}`; const btn = document.createElement('button'); btn.textContent = '✕'; - btn.onclick = async () => { - await api(`/api/killist?title=${encodeURIComponent(title)}`, { method: 'DELETE' }); - await refreshKillist(); + btn.onclick = () => { + send({ cmd: 'killist_remove', title }); + li.remove(); }; li.appendChild(btn); killistItems.appendChild(li); }); + killistPanel.classList.remove('hidden'); +}); + +$('btn-close-killist').addEventListener('click', () => + killistPanel.classList.add('hidden')); + +// ── Visualisation (Canvas) ──────────────────────────────────────────────────── +function applyViz(bars) { + if (!bars || bars.length === 0) return; + lastBars = new Float32Array(bars); } -// ── Polling ─────────────────────────────────────────────────────────────────── -async function poll() { - const st = await api('/api/status'); - if (!st) { - statusDot.className = 'err'; - statusDot.textContent = '●'; - stateLabel.textContent = 'Keine Verbindung'; - trackTitle.textContent = '–'; - return; - } +function renderFrame() { + rafId = requestAnimationFrame(renderFrame); - if (!st.running) { - statusDot.className = 'err'; - stateLabel.textContent = 'Winamp nicht gestartet'; - trackTitle.textContent = '–'; - return; + // Resize canvas to CSS size (handles window resize / DPR) + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth; + const cssH = canvas.clientHeight; + if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + ctx2d.scale(dpr, dpr); } - statusDot.className = 'ok'; - const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; - stateLabel.textContent = stateMap[st.state] ?? st.state; + const w = cssW; + const h = cssH; + const n = lastBars.length || NUM_BARS; - trackTitle.textContent = st.title || '–'; - playlistPos.textContent = st.playlist_length - ? `${st.playlist_pos} / ${st.playlist_length}` - : ''; + // Background + ctx2d.fillStyle = '#000'; + ctx2d.fillRect(0, 0, w, h); - if (st.length > 0) { - progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; - timeCurrent.textContent = fmtTime(st.position); - timeLength.textContent = fmtTime(st.length); - } else { - progressFill.style.width = '0%'; - } + const gap = 1; + const barW = (w - gap * (n - 1)) / n; - // Reflect play/pause state on button - btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; + for (let i = 0; i < n; i++) { + const val = lastBars[i] || 0; - // System volume (always present in status response) - if (typeof st.volume === 'number') { - currentVolume = st.volume; - updateVolumeFill(st.muted); - updateMuteBtn(st.muted); - } -} + // Peak: fast rise, slow fall (2% per frame) + if (val > peaks[i]) peaks[i] = val; + else peaks[i] = Math.max(0, peaks[i] - 0.012); -function startPolling(intervalMs = 2000) { - if (pollTimer) clearInterval(pollTimer); - poll(); - pollTimer = setInterval(poll, intervalMs); + const x = i * (barW + gap); + const barH = val * h; + + // Bar colour: green (120°) → yellow (60°) → red (0°) based on amplitude + const hue = Math.round((1 - val) * 120); + ctx2d.fillStyle = `hsl(${hue},100%,42%)`; + ctx2d.fillRect(x, h - barH, barW, barH); + + // Peak indicator — thin white line + if (peaks[i] > 0.02) { + const py = h - peaks[i] * h - 1; + ctx2d.fillStyle = 'rgba(255,255,255,0.75)'; + ctx2d.fillRect(x, py, barW, 2); + } + } } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -192,21 +250,27 @@ function fmtTime(secs) { return `${m}:${s}`; } -function escHtml(str) { - return str.replace(/&/g,'&').replace(//g,'>'); +function parseTime(str) { + const [m, s] = (str || '0:00').split(':').map(Number); + return m * 60 + (s || 0); +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); } let toastTimer; function showToast(msg) { - let el = document.getElementById('toast'); + let el = $('toast'); if (!el) { el = document.createElement('div'); el.id = 'toast'; - el.style.cssText = ` - position:fixed;bottom:24px;left:50%;transform:translateX(-50%); - background:#333;color:#fff;padding:10px 20px;border-radius:8px; - font-size:14px;z-index:999;opacity:0;transition:opacity .2s; - `; + el.style.cssText = [ + 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)', + 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px', + 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s', + 'pointer-events:none', + ].join(';'); document.body.appendChild(el); } el.textContent = msg; @@ -216,4 +280,5 @@ function showToast(msg) { } // ── Boot ────────────────────────────────────────────────────────────────────── -startPolling(2000); +connect(); +renderFrame(); diff --git a/web/static/index.html b/web/static/index.html index 0e49edd..b3422b7 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -18,6 +18,8 @@
+ +
diff --git a/web/static/style.css b/web/static/style.css index aec163a..599167f 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -62,6 +62,15 @@ html, body { margin-top: 4px; } +/* Visualisation canvas */ +#viz { + width: 100%; + height: 80px; + border-radius: var(--radius); + background: #000; + display: block; +} + /* Progress */ #progress-wrap { display: flex;