WebSocket (/ws):
- Replaces 2s HTTP polling with persistent WS connection
- Server pushes status updates every 500ms to all clients
- Server pushes FFT spectrum frames at ~30fps
- Bidirectional: client sends commands as JSON ({cmd, delta, level, ...})
- Auto-reconnect on disconnect (3s backoff)
- Status pushed immediately on connect
Visualisation (internal/viz/capture.go):
- WASAPI loopback capture via IAudioClient (same COM approach as volume)
- Captures whatever is playing through the default render device
- 2048-sample Hanning-windowed FFT (pure Go, no deps)
- 64 log-spaced bars, 40Hz-20kHz
- Fast attack / slow decay smoothing per bar
Canvas renderer (app.js):
- requestAnimationFrame loop, DPR-aware resize
- Green->yellow->red HSL gradient by amplitude
- Peak-hold indicators with 1.2%/frame decay
- Graceful: canvas stays dark if no viz data (Winamp paused/stopped)
Architecture:
- internal/server/hub.go: gorilla/websocket hub (register/broadcast/unregister)
- internal/server/server.go: full //go:build windows, REST API kept for debug
- frontend uses WS for all commands, REST only for killist list fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
| @@ -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 | |||
| ) | |||
| @@ -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= | |||
| @@ -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 | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -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 | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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 = `<span>${escHtml(title)}</span>`; | |||
| 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,'<').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, '<').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(); | |||
| @@ -18,6 +18,8 @@ | |||
| <div id="playlist-pos"></div> | |||
| </div> | |||
| <canvas id="viz" height="80"></canvas> | |||
| <div id="progress-wrap"> | |||
| <div id="progress-bar"> | |||
| <div id="progress-fill"></div> | |||
| @@ -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; | |||