Selaa lähdekoodia

feat: WebSocket + WASAPI spectrum visualisation

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
Jan Svabenik 1 kuukausi sitten
vanhempi
commit
7e508507b6
8 muutettua tiedostoa jossa 972 lisäystä ja 272 poistoa
  1. +1
    -0
      go.mod
  2. +2
    -0
      go.sum
  3. +120
    -0
      internal/server/hub.go
  4. +245
    -149
      internal/server/server.go
  5. +405
    -0
      internal/viz/capture.go
  6. +188
    -123
      web/static/app.js
  7. +2
    -0
      web/static/index.html
  8. +9
    -0
      web/static/style.css

+ 1
- 0
go.mod Näytä tiedosto

@@ -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
)

+ 2
- 0
go.sum Näytä tiedosto

@@ -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=


+ 120
- 0
internal/server/hub.go Näytä tiedosto

@@ -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
}
}
}
}

+ 245
- 149
internal/server/server.go Näytä tiedosto

@@ -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)


+ 405
- 0
internal/viz/capture.go Näytä tiedosto

@@ -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
}
}
}
}

+ 188
- 123
web/static/app.js Näytä tiedosto

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
function parseTime(str) {
const [m, s] = (str || '0:00').split(':').map(Number);
return m * 60 + (s || 0);
}

function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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();

+ 2
- 0
web/static/index.html Näytä tiedosto

@@ -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>


+ 9
- 0
web/static/style.css Näytä tiedosto

@@ -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;


Loading…
Peruuta
Tallenna