Browse Source

feat: initial project scaffold

- 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
Jan Svabenik 1 month ago
parent
commit
75dd444468
13 changed files with 1196 additions and 1 deletions
  1. +11
    -0
      .claude/launch.json
  2. +25
    -0
      .gitignore
  3. +37
    -1
      README.md
  4. +4
    -0
      config.yaml.example
  5. +5
    -0
      go.mod
  6. +3
    -0
      go.sum
  7. +93
    -0
      internal/killist/killist.go
  8. +51
    -0
      internal/resume/resume.go
  9. +285
    -0
      internal/server/server.go
  10. +226
    -0
      internal/winamp/winamp.go
  11. +196
    -0
      web/static/app.js
  12. +69
    -0
      web/static/index.html
  13. +191
    -0
      web/static/style.css

+ 11
- 0
.claude/launch.json View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "roadamp-frontend",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "3456", "--directory", "web/static"],
"port": 3456
}
]
}

+ 25
- 0
.gitignore View File

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

+ 37
- 1
README.md View File

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

+ 4
- 0
config.yaml.example View File

@@ -0,0 +1,4 @@
port: 8080
winamp_path: "C:\\Program Files\\Winamp\\Winamp.exe"
killist_file: "killist.dat"
resume_file: "resume.dat"

+ 5
- 0
go.mod View File

@@ -0,0 +1,5 @@
module git.svabi.ch/jan/roadamp

go 1.25.0

require gopkg.in/yaml.v3 v3.0.1 // indirect

+ 3
- 0
go.sum View File

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

+ 93
- 0
internal/killist/killist.go View File

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

+ 51
- 0
internal/resume/resume.go View File

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

+ 285
- 0
internal/server/server.go View File

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

+ 226
- 0
internal/winamp/winamp.go View File

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

+ 196
- 0
web/static/app.js View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

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

+ 69
- 0
web/static/index.html View File

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

+ 191
- 0
web/static/style.css View File

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

Loading…
Cancel
Save