diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..338cc57 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "roadamp-frontend", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "3456", "--directory", "web/static"], + "port": 3456 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..270e6a5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index c07a439..f10b663 100644 --- a/README.md +++ b/README.md @@ -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" +``` diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..86883af --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,4 @@ +port: 8080 +winamp_path: "C:\\Program Files\\Winamp\\Winamp.exe" +killist_file: "killist.dat" +resume_file: "resume.dat" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c1484a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.svabi.ch/jan/roadamp + +go 1.25.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/killist/killist.go b/internal/killist/killist.go new file mode 100644 index 0000000..2c901a3 --- /dev/null +++ b/internal/killist/killist.go @@ -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() +} diff --git a/internal/resume/resume.go b/internal/resume/resume.go new file mode 100644 index 0000000..f9731c4 --- /dev/null +++ b/internal/resume/resume.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..d6c8951 --- /dev/null +++ b/internal/server/server.go @@ -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) +} diff --git a/internal/winamp/winamp.go b/internal/winamp/winamp.go new file mode 100644 index 0000000..6e7662a --- /dev/null +++ b/internal/winamp/winamp.go @@ -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 +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..2cfd3e8 --- /dev/null +++ b/web/static/app.js @@ -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 = `${escHtml(title)}`; + 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,'>'); +} + +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); diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..fa9c83c --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,69 @@ + + + + + + roadamp + + + +
+
+ โ— + โ€“ +
+ +
+
Nicht verbunden
+
+
+ +
+
+
+
+
+ 0:00 + 0:00 +
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ +
+
+
+
+
+ +
+ +
+ + +
+ + +
+ + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..d1d2346 --- /dev/null +++ b/web/static/style.css @@ -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; }