- Go HTTP server with Winamp IPC via WM_USER/WM_COMMAND - internal/winamp: TWinampControl port (play/pause/stop/seek/volume/title/playlist) - internal/killist: persistent skip-list (port of Delphi KillFile feature) - internal/resume: JSON-based resume state (port of Delphi resume.dat) - internal/server: HTTP API + background killist checker + resume restore - web/static: mobile-first touch UI (dark theme, progress bar, seek buttons) - config.yaml.example for winamp path / port / file paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
| @@ -0,0 +1,11 @@ | |||
| { | |||
| "version": "0.0.1", | |||
| "configurations": [ | |||
| { | |||
| "name": "roadamp-frontend", | |||
| "runtimeExecutable": "python3", | |||
| "runtimeArgs": ["-m", "http.server", "3456", "--directory", "web/static"], | |||
| "port": 3456 | |||
| } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| # Binaries | |||
| roadamp | |||
| roadamp.exe | |||
| *.exe | |||
| # Build output | |||
| dist/ | |||
| bin/ | |||
| # Go | |||
| *.test | |||
| *.out | |||
| vendor/ | |||
| # IDE | |||
| .idea/ | |||
| .vscode/ | |||
| *.swp | |||
| # OS | |||
| .DS_Store | |||
| Thumbs.db | |||
| # Config with secrets | |||
| config.local.yaml | |||
| @@ -1 +1,37 @@ | |||
| # roadamp | |||
| # roadamp 🚗🎵 | |||
| Web-based Winamp controller for CarPC setups — optimized for tablets and phones. | |||
| A modern rebuild of a classic Delphi CarPC project, written in Go with a responsive web interface. | |||
| ## Features | |||
| - Play / Pause / Stop | |||
| - Next / Previous Track | |||
| - Seek ±15s / ±120s (with auto-mute during seek) | |||
| - Master volume control | |||
| - Track progress display | |||
| - KillList — auto-skip unwanted tracks | |||
| - Resume — restores position after restart | |||
| ## Stack | |||
| - **Backend:** Go (HTTP server + Winamp IPC via Windows messages) | |||
| - **Frontend:** Vanilla JS / HTML / CSS — no framework, mobile-first | |||
| ## Getting Started | |||
| ```bash | |||
| go build ./cmd/roadamp | |||
| ./roadamp.exe | |||
| # Open http://localhost:8080 on your tablet/phone | |||
| ``` | |||
| ## Configuration | |||
| Copy `config.yaml.example` to `config.yaml` and adjust: | |||
| ```yaml | |||
| port: 8080 | |||
| winamp_path: "C:\\Program Files\\Winamp\\Winamp.exe" | |||
| ``` | |||
| @@ -0,0 +1,4 @@ | |||
| port: 8080 | |||
| winamp_path: "C:\\Program Files\\Winamp\\Winamp.exe" | |||
| killist_file: "killist.dat" | |||
| resume_file: "resume.dat" | |||
| @@ -0,0 +1,5 @@ | |||
| module git.svabi.ch/jan/roadamp | |||
| go 1.25.0 | |||
| require gopkg.in/yaml.v3 v3.0.1 // indirect | |||
| @@ -0,0 +1,3 @@ | |||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |||
| @@ -0,0 +1,93 @@ | |||
| // Package killist maintains a persistent list of track titles that should | |||
| // be automatically skipped during playback — a direct port of the Delphi | |||
| // KillList / KillFile feature. | |||
| package killist | |||
| import ( | |||
| "bufio" | |||
| "os" | |||
| "strings" | |||
| "sync" | |||
| ) | |||
| // KillList is a thread-safe set of track titles to skip. | |||
| type KillList struct { | |||
| mu sync.RWMutex | |||
| titles map[string]struct{} | |||
| filepath string | |||
| } | |||
| // Load reads the kill list from disk (creates an empty list if the file | |||
| // does not exist yet). | |||
| func Load(path string) (*KillList, error) { | |||
| kl := &KillList{ | |||
| titles: make(map[string]struct{}), | |||
| filepath: path, | |||
| } | |||
| f, err := os.Open(path) | |||
| if os.IsNotExist(err) { | |||
| return kl, nil | |||
| } | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| defer f.Close() | |||
| sc := bufio.NewScanner(f) | |||
| for sc.Scan() { | |||
| line := strings.TrimSpace(sc.Text()) | |||
| if line != "" { | |||
| kl.titles[line] = struct{}{} | |||
| } | |||
| } | |||
| return kl, sc.Err() | |||
| } | |||
| // Contains reports whether the given title is on the kill list. | |||
| func (kl *KillList) Contains(title string) bool { | |||
| kl.mu.RLock() | |||
| defer kl.mu.RUnlock() | |||
| _, ok := kl.titles[title] | |||
| return ok | |||
| } | |||
| // Add appends a title and persists the list. | |||
| func (kl *KillList) Add(title string) error { | |||
| kl.mu.Lock() | |||
| defer kl.mu.Unlock() | |||
| kl.titles[title] = struct{}{} | |||
| return kl.save() | |||
| } | |||
| // Remove removes a title and persists the list. | |||
| func (kl *KillList) Remove(title string) error { | |||
| kl.mu.Lock() | |||
| defer kl.mu.Unlock() | |||
| delete(kl.titles, title) | |||
| return kl.save() | |||
| } | |||
| // List returns all titles currently on the kill list. | |||
| func (kl *KillList) List() []string { | |||
| kl.mu.RLock() | |||
| defer kl.mu.RUnlock() | |||
| out := make([]string, 0, len(kl.titles)) | |||
| for t := range kl.titles { | |||
| out = append(out, t) | |||
| } | |||
| return out | |||
| } | |||
| func (kl *KillList) save() error { | |||
| f, err := os.Create(kl.filepath) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| defer f.Close() | |||
| w := bufio.NewWriter(f) | |||
| for t := range kl.titles { | |||
| w.WriteString(t + "\n") | |||
| } | |||
| return w.Flush() | |||
| } | |||
| @@ -0,0 +1,51 @@ | |||
| // Package resume persists and restores the playback position across | |||
| // application restarts — a port of the Delphi setresume/getresume feature. | |||
| package resume | |||
| import ( | |||
| "encoding/json" | |||
| "errors" | |||
| "os" | |||
| ) | |||
| // State holds enough information to restore a playback session. | |||
| type State struct { | |||
| PlaylistLength int `json:"playlist_length"` | |||
| PlaylistPos int `json:"playlist_pos"` | |||
| OffsetSeconds int `json:"offset_seconds"` | |||
| TrackTitle string `json:"track_title"` | |||
| } | |||
| // Save persists the given state to a JSON file. | |||
| func Save(path string, s State) error { | |||
| data, err := json.Marshal(s) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| return os.WriteFile(path, data, 0644) | |||
| } | |||
| // Load reads the state from disk. Returns (nil, nil) if no file exists yet. | |||
| func Load(path string) (*State, error) { | |||
| data, err := os.ReadFile(path) | |||
| if errors.Is(err, os.ErrNotExist) { | |||
| return nil, nil | |||
| } | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var s State | |||
| if err := json.Unmarshal(data, &s); err != nil { | |||
| return nil, err | |||
| } | |||
| return &s, nil | |||
| } | |||
| // Delete removes the resume file (call after successful restore). | |||
| func Delete(path string) error { | |||
| err := os.Remove(path) | |||
| if errors.Is(err, os.ErrNotExist) { | |||
| return nil | |||
| } | |||
| return err | |||
| } | |||
| @@ -0,0 +1,285 @@ | |||
| // Package server wires together the HTTP API, the Winamp controller, | |||
| // killist, resume, and the embedded web frontend. | |||
| package server | |||
| import ( | |||
| "encoding/json" | |||
| "fmt" | |||
| "log" | |||
| "net/http" | |||
| "os" | |||
| "os/exec" | |||
| "strconv" | |||
| "time" | |||
| "git.svabi.ch/jan/roadamp/internal/killist" | |||
| "git.svabi.ch/jan/roadamp/internal/resume" | |||
| "git.svabi.ch/jan/roadamp/internal/winamp" | |||
| "gopkg.in/yaml.v3" | |||
| ) | |||
| // Config holds runtime configuration loaded from config.yaml. | |||
| type Config struct { | |||
| Port int `yaml:"port"` | |||
| WinampPath string `yaml:"winamp_path"` | |||
| KillListFile string `yaml:"killist_file"` | |||
| ResumeFile string `yaml:"resume_file"` | |||
| } | |||
| func loadConfig(path string) (Config, error) { | |||
| cfg := Config{ | |||
| Port: 8080, | |||
| WinampPath: `C:\Program Files\Winamp\Winamp.exe`, | |||
| KillListFile: "killist.dat", | |||
| ResumeFile: "resume.dat", | |||
| } | |||
| data, err := os.ReadFile(path) | |||
| if os.IsNotExist(err) { | |||
| return cfg, nil | |||
| } | |||
| if err != nil { | |||
| return cfg, err | |||
| } | |||
| return cfg, yaml.Unmarshal(data, &cfg) | |||
| } | |||
| // Server is the roadamp HTTP server. | |||
| type Server struct { | |||
| cfg Config | |||
| wa *winamp.Controller | |||
| kl *killist.KillList | |||
| mux *http.ServeMux | |||
| } | |||
| func New(configPath string) (*Server, error) { | |||
| cfg, err := loadConfig(configPath) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("config: %w", err) | |||
| } | |||
| kl, err := killist.Load(cfg.KillListFile) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("killist: %w", err) | |||
| } | |||
| s := &Server{ | |||
| cfg: cfg, | |||
| wa: winamp.New(), | |||
| kl: kl, | |||
| mux: http.NewServeMux(), | |||
| } | |||
| s.routes() | |||
| return s, nil | |||
| } | |||
| func (s *Server) Run() error { | |||
| // Attempt to restore resume state on startup. | |||
| go s.restoreResume() | |||
| // Background killist checker. | |||
| go s.killChecker() | |||
| addr := fmt.Sprintf(":%d", s.cfg.Port) | |||
| log.Printf("roadamp listening on http://localhost%s", addr) | |||
| return http.ListenAndServe(addr, s.mux) | |||
| } | |||
| // ── Routes ────────────────────────────────────────────────────────────────── | |||
| func (s *Server) routes() { | |||
| // Static frontend | |||
| s.mux.Handle("/", http.FileServer(http.Dir("web/static"))) | |||
| // API | |||
| 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) // ?level=0-255 | |||
| s.mux.HandleFunc("/api/killist", s.handleKillist) | |||
| s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | |||
| } | |||
| // ── Handlers ───────────────────────────────────────────────────────────────── | |||
| type statusResponse struct { | |||
| Running bool `json:"running"` | |||
| State string `json:"state"` | |||
| Title string `json:"title"` | |||
| Position int `json:"position"` | |||
| Length int `json:"length"` | |||
| PlaylistPos int `json:"playlist_pos"` | |||
| PlaylistLength int `json:"playlist_length"` | |||
| Version string `json:"version"` | |||
| } | |||
| func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |||
| resp := statusResponse{Running: s.wa.IsRunning()} | |||
| if resp.Running { | |||
| switch s.wa.PlayState() { | |||
| case 1: | |||
| resp.State = "playing" | |||
| case 3: | |||
| resp.State = "paused" | |||
| default: | |||
| resp.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() | |||
| } | |||
| jsonOK(w, resp) | |||
| } | |||
| func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { | |||
| ok := s.wa.Play() | |||
| jsonOK(w, map[string]bool{"ok": ok}) | |||
| } | |||
| func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { | |||
| ok := s.wa.Pause() | |||
| jsonOK(w, map[string]bool{"ok": ok}) | |||
| } | |||
| 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(), | |||
| PlaylistPos: s.wa.GetPlaylistPosition(), | |||
| OffsetSeconds: s.wa.GetPosition(), | |||
| 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}) | |||
| } | |||
| func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { | |||
| delta, err := strconv.Atoi(r.URL.Query().Get("delta")) | |||
| if err != nil { | |||
| http.Error(w, "invalid delta", http.StatusBadRequest) | |||
| return | |||
| } | |||
| pos := s.wa.GetPosition() + delta | |||
| if pos < 0 { | |||
| pos = 0 | |||
| } | |||
| ok := s.wa.Seek(pos) | |||
| jsonOK(w, map[string]bool{"ok": ok}) | |||
| } | |||
| func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { | |||
| lvl, err := strconv.Atoi(r.URL.Query().Get("level")) | |||
| if err != nil { | |||
| http.Error(w, "invalid level", http.StatusBadRequest) | |||
| return | |||
| } | |||
| ok := s.wa.SetVolume(lvl) | |||
| jsonOK(w, map[string]bool{"ok": ok}) | |||
| } | |||
| func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { | |||
| switch r.Method { | |||
| case http.MethodGet: | |||
| jsonOK(w, s.kl.List()) | |||
| case http.MethodPost: | |||
| title := s.wa.GetTitle() | |||
| if title == "" { | |||
| 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 | |||
| } | |||
| 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) | |||
| } | |||
| } | |||
| func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { | |||
| if s.wa.IsRunning() { | |||
| 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) | |||
| 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,226 @@ | |||
| //go:build windows | |||
| // Package winamp provides IPC control of a running Winamp instance via | |||
| // Windows messages (WM_COMMAND / WM_USER), mirroring the TWinampControl | |||
| // Delphi component by SpECTre. | |||
| package winamp | |||
| import ( | |||
| "fmt" | |||
| "syscall" | |||
| "unsafe" | |||
| ) | |||
| var ( | |||
| user32 = syscall.NewLazyDLL("user32.dll") | |||
| findWindow = user32.NewProc("FindWindowW") | |||
| sendMessage = user32.NewProc("SendMessageW") | |||
| getWindowTextW = user32.NewProc("GetWindowTextW") | |||
| ) | |||
| const ( | |||
| wmCommand = 0x0111 | |||
| wmUser = 0x0400 | |||
| // Winamp WM_COMMAND IDs | |||
| cmdPrevTrack = 40044 | |||
| cmdPlay = 40045 | |||
| cmdPause = 40046 | |||
| cmdStop = 40047 | |||
| cmdNextTrack = 40048 | |||
| cmdFadeStop = 40147 | |||
| cmdStopAfter = 40157 | |||
| cmdToggleRepeat = 40022 | |||
| cmdToggleShuffle = 40023 | |||
| cmdClose = 40001 | |||
| cmdVolumeUp = 40058 | |||
| cmdVolumeDown = 40059 | |||
| // Winamp WM_USER lParam IDs | |||
| userGetVersion = 0 | |||
| userGetPlayState = 104 | |||
| userGetPosition = 105 | |||
| userSeek = 106 | |||
| userSetVolume = 122 | |||
| userGetPlaylistPos = 125 | |||
| userGetPlaylistLen = 124 | |||
| userRestart = 135 | |||
| ) | |||
| // Controller talks to a running Winamp instance. | |||
| type Controller struct{} | |||
| func New() *Controller { return &Controller{} } | |||
| func (c *Controller) handle() syscall.Handle { | |||
| winampClass, _ := syscall.UTF16PtrFromString("Winamp v1.x") | |||
| h, _, _ := findWindow.Call( | |||
| uintptr(unsafe.Pointer(winampClass)), | |||
| 0, | |||
| ) | |||
| return syscall.Handle(h) | |||
| } | |||
| func (c *Controller) IsRunning() bool { | |||
| return c.handle() != 0 | |||
| } | |||
| func send(h syscall.Handle, msg, wparam, lparam uintptr) uintptr { | |||
| r, _, _ := sendMessage.Call(uintptr(h), msg, wparam, lparam) | |||
| return r | |||
| } | |||
| func (c *Controller) cmd(id uintptr) bool { | |||
| h := c.handle() | |||
| if h == 0 { | |||
| return false | |||
| } | |||
| send(h, wmCommand, id, 0) | |||
| return true | |||
| } | |||
| func (c *Controller) user(wparam, lparam uintptr) (uintptr, bool) { | |||
| h := c.handle() | |||
| if h == 0 { | |||
| return 0, false | |||
| } | |||
| return send(h, wmUser, wparam, lparam), true | |||
| } | |||
| // Playback controls | |||
| func (c *Controller) Play() bool { return c.cmd(cmdPlay) } | |||
| func (c *Controller) Pause() bool { return c.cmd(cmdPause) } | |||
| func (c *Controller) Stop() bool { return c.cmd(cmdStop) } | |||
| func (c *Controller) NextTrack() bool { return c.cmd(cmdNextTrack) } | |||
| func (c *Controller) PrevTrack() bool { return c.cmd(cmdPrevTrack) } | |||
| func (c *Controller) Close() bool { return c.cmd(cmdClose) } | |||
| // State: 0=stopped, 1=playing, 3=paused | |||
| func (c *Controller) PlayState() int { | |||
| v, ok := c.user(0, userGetPlayState) | |||
| if !ok { | |||
| return 0 | |||
| } | |||
| return int(v) | |||
| } | |||
| func (c *Controller) IsPlaying() bool { return c.PlayState() == 1 } | |||
| func (c *Controller) IsPaused() bool { return c.PlayState() == 3 } | |||
| func (c *Controller) IsStopped() bool { return c.PlayState() == 0 } | |||
| // GetPosition returns current playback offset in seconds. | |||
| func (c *Controller) GetPosition() int { | |||
| v, ok := c.user(0, userGetPosition) | |||
| if !ok { | |||
| return 0 | |||
| } | |||
| return int(v) / 1000 | |||
| } | |||
| // GetLength returns total track length in seconds. | |||
| func (c *Controller) GetLength() int { | |||
| v, ok := c.user(1, userGetPosition) | |||
| if !ok { | |||
| return 0 | |||
| } | |||
| return int(v) | |||
| } | |||
| // Seek sets the playback position to offsetSeconds. | |||
| func (c *Controller) Seek(offsetSeconds int) bool { | |||
| h := c.handle() | |||
| if h == 0 { | |||
| return false | |||
| } | |||
| send(h, wmUser, uintptr(offsetSeconds*1000), userSeek) | |||
| return true | |||
| } | |||
| // SetVolume sets Winamp's internal volume (0–255). | |||
| func (c *Controller) SetVolume(v int) bool { | |||
| if v < 0 { | |||
| v = 0 | |||
| } | |||
| if v > 255 { | |||
| v = 255 | |||
| } | |||
| h := c.handle() | |||
| if h == 0 { | |||
| return false | |||
| } | |||
| send(h, wmUser, uintptr(v), userSetVolume) | |||
| return true | |||
| } | |||
| // GetPlaylistPosition returns the 1-based current playlist index. | |||
| func (c *Controller) GetPlaylistPosition() int { | |||
| v, ok := c.user(0, userGetPlaylistPos) | |||
| if !ok { | |||
| return 0 | |||
| } | |||
| return int(v) + 1 | |||
| } | |||
| // GetPlaylistLength returns the total number of tracks in the playlist. | |||
| func (c *Controller) GetPlaylistLength() int { | |||
| v, ok := c.user(0, userGetPlaylistLen) | |||
| if !ok { | |||
| return 0 | |||
| } | |||
| return int(v) | |||
| } | |||
| // GetVersion returns a human-readable Winamp version string (e.g. "5.66"). | |||
| func (c *Controller) GetVersion() string { | |||
| v, ok := c.user(0, userGetVersion) | |||
| if !ok { | |||
| return "" | |||
| } | |||
| hex := fmt.Sprintf("%04X", v) | |||
| if len(hex) < 3 { | |||
| return "" | |||
| } | |||
| return string(hex[0]) + "." + hex[1:3] | |||
| } | |||
| // GetTitle returns the title of the currently playing track by reading | |||
| // the Winamp window title (format: "N. Artist - Track - Winamp"). | |||
| func (c *Controller) GetTitle() string { | |||
| h := c.handle() | |||
| if h == 0 { | |||
| return "" | |||
| } | |||
| buf := make([]uint16, 512) | |||
| getWindowTextW.Call(uintptr(h), uintptr(unsafe.Pointer(&buf[0])), 512) | |||
| title := syscall.UTF16ToString(buf) | |||
| // Strip trailing " - Winamp" suffix | |||
| const suffix = " - Winamp" | |||
| if idx := lastIndex(title, suffix); idx >= 0 { | |||
| title = title[:idx] | |||
| } | |||
| // Strip leading playlist-number prefix "NNN. " | |||
| if idx := indexOf(title, ". "); idx >= 0 { | |||
| title = title[idx+2:] | |||
| } | |||
| return title | |||
| } | |||
| func lastIndex(s, sub string) int { | |||
| last := -1 | |||
| for i := 0; i <= len(s)-len(sub); i++ { | |||
| if s[i:i+len(sub)] == sub { | |||
| last = i | |||
| } | |||
| } | |||
| return last | |||
| } | |||
| func indexOf(s, sub string) int { | |||
| for i := 0; i <= len(s)-len(sub); i++ { | |||
| if s[i:i+len(sub)] == sub { | |||
| return i | |||
| } | |||
| } | |||
| return -1 | |||
| } | |||
| @@ -0,0 +1,196 @@ | |||
| 'use strict'; | |||
| const api = (path, opts = {}) => | |||
| fetch(path, opts).then(r => r.json()).catch(() => null); | |||
| // ── State ───────────────────────────────────────────────────────────────────── | |||
| let currentVolume = 180; // 0–255 | |||
| let pollTimer = null; | |||
| // ── DOM refs ────────────────────────────────────────────────────────────────── | |||
| const $ = id => document.getElementById(id); | |||
| const statusDot = $('winamp-status'); | |||
| const stateLabel = $('state-label'); | |||
| const trackTitle = $('track-title'); | |||
| const playlistPos = $('playlist-pos'); | |||
| const progressFill = $('progress-fill'); | |||
| const timeCurrent = $('time-current'); | |||
| const timeLength = $('time-length'); | |||
| const volumeFill = $('volume-fill'); | |||
| const btnPlay = $('btn-play'); | |||
| const killistPanel = $('killist-panel'); | |||
| const killistItems = $('killist-items'); | |||
| // ── 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' }); | |||
| } else { | |||
| await api('/api/play', { method: 'POST' }); | |||
| } | |||
| 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(); | |||
| }); | |||
| // ── Seek buttons ────────────────────────────────────────────────────────────── | |||
| 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(); | |||
| }); | |||
| }); | |||
| // ── Progress bar click-to-seek ──────────────────────────────────────────────── | |||
| $('progress-bar').addEventListener('click', async e => { | |||
| 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(); | |||
| }); | |||
| // ── Volume ──────────────────────────────────────────────────────────────────── | |||
| $('btn-vol-up').addEventListener('click', async () => { | |||
| currentVolume = Math.min(255, currentVolume + 13); // ~5% | |||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||
| updateVolumeFill(); | |||
| }); | |||
| $('btn-vol-down').addEventListener('click', async () => { | |||
| currentVolume = Math.max(0, currentVolume - 13); | |||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||
| updateVolumeFill(); | |||
| }); | |||
| $('volume-bar').addEventListener('click', async e => { | |||
| const rect = e.currentTarget.getBoundingClientRect(); | |||
| currentVolume = Math.round((e.clientX - rect.left) / rect.width * 255); | |||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||
| updateVolumeFill(); | |||
| }); | |||
| function updateVolumeFill() { | |||
| volumeFill.style.width = (currentVolume / 255 * 100) + '%'; | |||
| } | |||
| // ── KillList ────────────────────────────────────────────────────────────────── | |||
| $('btn-kill').addEventListener('click', async () => { | |||
| const res = await api('/api/killist', { method: 'POST' }); | |||
| if (res?.added) { | |||
| showToast(`🚫 ${res.added}`); | |||
| } | |||
| }); | |||
| $('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; | |||
| killistItems.innerHTML = ''; | |||
| 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(); | |||
| }; | |||
| li.appendChild(btn); | |||
| killistItems.appendChild(li); | |||
| }); | |||
| } | |||
| // ── Polling ─────────────────────────────────────────────────────────────────── | |||
| async function poll() { | |||
| const st = await api('/api/status'); | |||
| if (!st) { | |||
| statusDot.className = 'err'; | |||
| statusDot.textContent = '●'; | |||
| stateLabel.textContent = 'Keine Verbindung'; | |||
| trackTitle.textContent = '–'; | |||
| return; | |||
| } | |||
| if (!st.running) { | |||
| statusDot.className = 'err'; | |||
| stateLabel.textContent = 'Winamp nicht gestartet'; | |||
| trackTitle.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 { | |||
| progressFill.style.width = '0%'; | |||
| } | |||
| // Reflect play/pause state on button | |||
| btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; | |||
| } | |||
| function startPolling(intervalMs = 2000) { | |||
| if (pollTimer) clearInterval(pollTimer); | |||
| poll(); | |||
| pollTimer = setInterval(poll, intervalMs); | |||
| } | |||
| // ── Helpers ─────────────────────────────────────────────────────────────────── | |||
| function fmtTime(secs) { | |||
| const m = Math.floor(secs / 60); | |||
| const s = String(Math.floor(secs % 60)).padStart(2, '0'); | |||
| return `${m}:${s}`; | |||
| } | |||
| function escHtml(str) { | |||
| return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |||
| } | |||
| let toastTimer; | |||
| function showToast(msg) { | |||
| let el = document.getElementById('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; | |||
| `; | |||
| document.body.appendChild(el); | |||
| } | |||
| el.textContent = msg; | |||
| el.style.opacity = '1'; | |||
| clearTimeout(toastTimer); | |||
| toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500); | |||
| } | |||
| // ── Boot ────────────────────────────────────────────────────────────────────── | |||
| startPolling(2000); | |||
| @@ -0,0 +1,69 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head> | |||
| <meta charset="UTF-8" /> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> | |||
| <title>roadamp</title> | |||
| <link rel="stylesheet" href="style.css" /> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| <div id="status-bar"> | |||
| <span id="winamp-status">●</span> | |||
| <span id="state-label">–</span> | |||
| </div> | |||
| <div id="track-info"> | |||
| <div id="track-title">Nicht verbunden</div> | |||
| <div id="playlist-pos"></div> | |||
| </div> | |||
| <div id="progress-wrap"> | |||
| <div id="progress-bar"> | |||
| <div id="progress-fill"></div> | |||
| </div> | |||
| <div id="time-display"> | |||
| <span id="time-current">0:00</span> | |||
| <span id="time-length">0:00</span> | |||
| </div> | |||
| </div> | |||
| <div id="seek-row"> | |||
| <button class="btn btn-seek" data-delta="-120">−2min</button> | |||
| <button class="btn btn-seek" data-delta="-15">−15s</button> | |||
| <button class="btn btn-seek" data-delta="+15">+15s</button> | |||
| <button class="btn btn-seek" data-delta="+120">+2min</button> | |||
| </div> | |||
| <div id="controls-row"> | |||
| <button class="btn btn-ctrl" id="btn-prev">⏮</button> | |||
| <button class="btn btn-ctrl btn-play" id="btn-play">▶</button> | |||
| <button class="btn btn-ctrl" id="btn-stop">⏹</button> | |||
| <button class="btn btn-ctrl" id="btn-next">⏭</button> | |||
| </div> | |||
| <div id="volume-row"> | |||
| <button class="btn btn-vol" id="btn-vol-down">🔉</button> | |||
| <div id="volume-bar-wrap"> | |||
| <div id="volume-bar"> | |||
| <div id="volume-fill"></div> | |||
| </div> | |||
| </div> | |||
| <button class="btn btn-vol" id="btn-vol-up">🔊</button> | |||
| </div> | |||
| <div id="killist-row"> | |||
| <button class="btn btn-kill" id="btn-kill">🚫 Überspringen</button> | |||
| <button class="btn btn-kill-list" id="btn-show-killist">Liste</button> | |||
| </div> | |||
| <div id="killist-panel" class="hidden"> | |||
| <h3>Skip-Liste</h3> | |||
| <ul id="killist-items"></ul> | |||
| <button class="btn" id="btn-close-killist">Schliessen</button> | |||
| </div> | |||
| </div> | |||
| <script src="app.js"></script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,191 @@ | |||
| :root { | |||
| --bg: #1a1a2e; | |||
| --surface: #16213e; | |||
| --accent: #e94560; | |||
| --accent2: #0f3460; | |||
| --text: #eaeaea; | |||
| --text-dim: #888; | |||
| --radius: 12px; | |||
| --btn-h: 64px; | |||
| } | |||
| * { box-sizing: border-box; margin: 0; padding: 0; } | |||
| html, body { | |||
| height: 100%; | |||
| background: var(--bg); | |||
| color: var(--text); | |||
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |||
| -webkit-tap-highlight-color: transparent; | |||
| user-select: none; | |||
| } | |||
| #app { | |||
| max-width: 600px; | |||
| margin: 0 auto; | |||
| padding: 16px; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 16px; | |||
| min-height: 100vh; | |||
| } | |||
| /* Status bar */ | |||
| #status-bar { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 8px; | |||
| font-size: 12px; | |||
| color: var(--text-dim); | |||
| } | |||
| #winamp-status { font-size: 16px; } | |||
| #winamp-status.ok { color: #4caf50; } | |||
| #winamp-status.err { color: var(--accent); } | |||
| /* Track info */ | |||
| #track-info { | |||
| background: var(--surface); | |||
| border-radius: var(--radius); | |||
| padding: 20px; | |||
| text-align: center; | |||
| } | |||
| #track-title { | |||
| font-size: 18px; | |||
| font-weight: 600; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| #playlist-pos { | |||
| font-size: 12px; | |||
| color: var(--text-dim); | |||
| margin-top: 4px; | |||
| } | |||
| /* Progress */ | |||
| #progress-wrap { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 4px; | |||
| } | |||
| #progress-bar { | |||
| height: 8px; | |||
| background: var(--accent2); | |||
| border-radius: 4px; | |||
| overflow: hidden; | |||
| cursor: pointer; | |||
| } | |||
| #progress-fill { | |||
| height: 100%; | |||
| background: var(--accent); | |||
| width: 0%; | |||
| transition: width 0.5s linear; | |||
| border-radius: 4px; | |||
| } | |||
| #time-display { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| font-size: 12px; | |||
| color: var(--text-dim); | |||
| } | |||
| /* Buttons base */ | |||
| .btn { | |||
| background: var(--surface); | |||
| color: var(--text); | |||
| border: none; | |||
| border-radius: var(--radius); | |||
| font-size: 22px; | |||
| cursor: pointer; | |||
| transition: background 0.15s, transform 0.08s; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| touch-action: manipulation; | |||
| } | |||
| .btn:active { transform: scale(0.93); background: var(--accent2); } | |||
| /* Seek row */ | |||
| #seek-row { | |||
| display: grid; | |||
| grid-template-columns: repeat(4, 1fr); | |||
| gap: 8px; | |||
| } | |||
| .btn-seek { | |||
| height: 52px; | |||
| font-size: 14px; | |||
| font-weight: 600; | |||
| } | |||
| /* Controls row */ | |||
| #controls-row { | |||
| display: grid; | |||
| grid-template-columns: repeat(4, 1fr); | |||
| gap: 8px; | |||
| } | |||
| .btn-ctrl { height: var(--btn-h); font-size: 28px; } | |||
| .btn-play { | |||
| background: var(--accent); | |||
| font-size: 32px; | |||
| } | |||
| .btn-play:active { background: #c73652; } | |||
| /* Volume row */ | |||
| #volume-row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 12px; | |||
| } | |||
| .btn-vol { width: 52px; height: 52px; flex-shrink: 0; } | |||
| #volume-bar-wrap { flex: 1; } | |||
| #volume-bar { | |||
| height: 8px; | |||
| background: var(--accent2); | |||
| border-radius: 4px; | |||
| overflow: hidden; | |||
| cursor: pointer; | |||
| } | |||
| #volume-fill { | |||
| height: 100%; | |||
| background: #4caf50; | |||
| width: 70%; | |||
| transition: width 0.2s; | |||
| border-radius: 4px; | |||
| } | |||
| /* Killist */ | |||
| #killist-row { | |||
| display: flex; | |||
| gap: 8px; | |||
| } | |||
| .btn-kill { flex: 1; height: 52px; font-size: 16px; background: #3a1a1a; } | |||
| .btn-kill:active { background: var(--accent); } | |||
| .btn-kill-list { width: 80px; height: 52px; font-size: 14px; } | |||
| #killist-panel { | |||
| background: var(--surface); | |||
| border-radius: var(--radius); | |||
| padding: 16px; | |||
| } | |||
| #killist-panel h3 { margin-bottom: 12px; } | |||
| #killist-items { list-style: none; display: flex; flex-direction: column; gap: 8px; } | |||
| #killist-items li { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| background: var(--bg); | |||
| border-radius: 8px; | |||
| padding: 8px 12px; | |||
| font-size: 14px; | |||
| } | |||
| #killist-items li button { | |||
| background: var(--accent); | |||
| color: white; | |||
| border: none; | |||
| border-radius: 6px; | |||
| padding: 4px 10px; | |||
| cursor: pointer; | |||
| } | |||
| #btn-close-killist { margin-top: 12px; width: 100%; height: 44px; font-size: 15px; } | |||
| .hidden { display: none !important; } | |||