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