From 49a22b8f36672fef6509551d1c4641c8c8f01f92 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Mon, 25 May 2026 21:29:52 +0200 Subject: [PATCH] feat: playlist browser + PWA Playlist browser: - GET /api/playlist returns all track titles via ReadProcessMemory - WS command {cmd:jump, index:N} jumps to track (0-based) - Full-screen overlay with scrollable list - Current track highlighted, auto-scrolls into view on open - Live highlight update when track changes while panel is open PWA: - manifest.json with standalone display + theme colour - sw.js: cache-first service worker for shell files - icon.svg: music-note icon on dark background - Apple/Android meta tags for Add to Homescreen Co-Authored-By: Claude Sonnet 4.6 --- internal/server/server.go | 8 +++ internal/winamp/winamp.go | 106 +++++++++++++++++++++++++++++++++----- web/static/app.js | 105 +++++++++++++++++++++++++++++++------ web/static/icon.svg | 8 +++ web/static/index.html | 22 ++++++++ web/static/manifest.json | 17 ++++++ web/static/style.css | 83 ++++++++++++++++++++++++++++- web/static/sw.js | 39 ++++++++++++++ 8 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 web/static/icon.svg create mode 100644 web/static/manifest.json create mode 100644 web/static/sw.js diff --git a/internal/server/server.go b/internal/server/server.go index 09a23c7..e030cfd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -109,6 +109,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/volume", s.handleVolume) s.mux.HandleFunc("/api/mute", s.handleMute) s.mux.HandleFunc("/api/killist", s.handleKillist) + s.mux.HandleFunc("/api/playlist", s.handlePlaylist) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } @@ -185,6 +186,7 @@ type wsCommand struct { Level int `json:"level"` Muted bool `json:"muted"` Title string `json:"title"` + Index int `json:"index"` // 0-based track index for "jump" } func (s *Server) handleCommand(raw []byte) { @@ -225,6 +227,8 @@ func (s *Server) handleCommand(raw []byte) { if title := s.wa.GetTitle(); title != "" { _ = s.kl.Add(title) } + case "jump": + s.wa.JumpToTrack(cmd.Index) case "killist_remove": _ = s.kl.Remove(cmd.Title) case "winamp_start": @@ -415,6 +419,10 @@ func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) { + jsonOK(w, s.wa.GetPlaylist()) +} + func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { if s.wa.IsRunning() { jsonOK(w, map[string]string{"status": "already_running"}) diff --git a/internal/winamp/winamp.go b/internal/winamp/winamp.go index e7c786a..e23d121 100644 --- a/internal/winamp/winamp.go +++ b/internal/winamp/winamp.go @@ -13,10 +13,14 @@ import ( ) var ( - user32 = syscall.NewLazyDLL("user32.dll") - findWindow = user32.NewProc("FindWindowW") - sendMessage = user32.NewProc("SendMessageW") - getWindowTextW = user32.NewProc("GetWindowTextW") + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + findWindow = user32.NewProc("FindWindowW") + sendMessage = user32.NewProc("SendMessageW") + getWindowTextW = user32.NewProc("GetWindowTextW") + getWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") + openProcess = kernel32.NewProc("OpenProcess") + readProcessMemory = kernel32.NewProc("ReadProcessMemory") ) const ( @@ -38,14 +42,19 @@ const ( cmdVolumeDown = 40059 // Winamp WM_USER lParam IDs - userGetVersion = 0 - userGetPlayState = 104 - userGetPosition = 105 - userSeek = 106 - userSetVolume = 122 - userGetPlaylistPos = 125 - userGetPlaylistLen = 124 - userRestart = 135 + userGetVersion = 0 + userGetPlayState = 104 + userGetPosition = 105 + userSeek = 106 + userSetVolume = 122 + userGetPlaylistPos = 125 + userGetPlaylistLen = 124 + userSetPlaylistPos = 121 + userGetPlaylistTitle = 212 + userRestart = 135 + + // OpenProcess access right + processVMRead = 0x0010 ) // Controller talks to a running Winamp instance. @@ -220,3 +229,76 @@ func (c *Controller) GetTitle() string { } return title } + +// ── Playlist ────────────────────────────────────────────────────────────────── + +// TrackInfo is one entry in the Winamp playlist. +type TrackInfo struct { + Index int `json:"index"` // 1-based + Title string `json:"title"` +} + +// readRemoteString reads a null-terminated ANSI string from another process. +func readRemoteString(proc syscall.Handle, ptr uintptr) string { + if ptr == 0 { + return "" + } + buf := make([]byte, 512) + var read uintptr + readProcessMemory.Call( + uintptr(proc), + ptr, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + uintptr(unsafe.Pointer(&read)), + ) + for i, b := range buf { + if b == 0 { + return string(buf[:i]) + } + } + return string(buf) +} + +// GetPlaylist returns all titles in the current Winamp playlist. +// Titles are read from Winamp's process memory via ReadProcessMemory. +func (c *Controller) GetPlaylist() []TrackInfo { + h := c.handle() + if h == 0 { + return nil + } + n := int(send(h, wmUser, 0, userGetPlaylistLen)) + if n <= 0 { + return nil + } + + var pid uint32 + getWindowThreadProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid))) + + proc, _, _ := openProcess.Call(processVMRead, 0, uintptr(pid)) + if proc == 0 { + return nil + } + defer syscall.CloseHandle(syscall.Handle(proc)) + + tracks := make([]TrackInfo, n) + for i := 0; i < n; i++ { + ptr := send(h, wmUser, uintptr(i), userGetPlaylistTitle) + tracks[i] = TrackInfo{ + Index: i + 1, + Title: readRemoteString(syscall.Handle(proc), ptr), + } + } + return tracks +} + +// JumpToTrack sets the playlist position (0-based) and starts playback. +func (c *Controller) JumpToTrack(zeroBasedIndex int) bool { + h := c.handle() + if h == 0 { + return false + } + send(h, wmUser, uintptr(zeroBasedIndex), userSetPlaylistPos) + send(h, wmCommand, cmdPlay, 0) + return true +} diff --git a/web/static/app.js b/web/static/app.js index 878ed2f..6a72643 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -3,24 +3,27 @@ // ── 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 volumePct = $('volume-pct'); -const btnMute = $('btn-mute'); -const btnPlay = $('btn-play'); -const killistPanel = $('killist-panel'); -const killistItems = $('killist-items'); -const canvas = $('viz'); -const ctx2d = canvas.getContext('2d'); +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 volumePct = $('volume-pct'); +const btnMute = $('btn-mute'); +const btnPlay = $('btn-play'); +const killistPanel = $('killist-panel'); +const killistItems = $('killist-items'); +const playlistOverlay = $('playlist-overlay'); +const playlistList = $('playlist-list'); +const canvas = $('viz'); +const ctx2d = canvas.getContext('2d'); // ── State ───────────────────────────────────────────────────────────────────── -let currentVolume = 50; +let currentVolume = 50; +let currentPlaylistPos = 0; // 1-based, updated from status let ws = null; let reconnectTimer = null; @@ -85,6 +88,11 @@ function applyStatus(st) { playlistPos.textContent = st.playlist_length ? `${st.playlist_pos} / ${st.playlist_length}` : ''; + if (st.playlist_pos !== currentPlaylistPos) { + currentPlaylistPos = st.playlist_pos; + updatePlaylistHighlight(); + } + if (st.length > 0) { progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; timeCurrent.textContent = fmtTime(st.position); @@ -316,6 +324,71 @@ function showToast(msg) { toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500); } +// ── Playlist ────────────────────────────────────────────────────────────────── +$('btn-show-playlist').addEventListener('click', openPlaylist); +$('btn-close-playlist').addEventListener('click', () => { + playlistOverlay.classList.add('hidden'); +}); + +async function openPlaylist() { + playlistOverlay.classList.remove('hidden'); + playlistList.innerHTML = '
  • Lade…
  • '; + + let tracks; + try { + tracks = await fetch('/api/playlist').then(r => r.json()); + } catch { + playlistList.innerHTML = '
  • Fehler beim Laden
  • '; + return; + } + + if (!tracks || tracks.length === 0) { + playlistList.innerHTML = '
  • Playlist leer
  • '; + return; + } + + const frag = document.createDocumentFragment(); + tracks.forEach(t => { + const li = document.createElement('li'); + li.dataset.index = t.index - 1; // store 0-based for jump + if (t.index === currentPlaylistPos) li.classList.add('current'); + + const idx = document.createElement('span'); + idx.className = 'pl-idx'; + idx.textContent = t.index; + + const title = document.createElement('span'); + title.className = 'pl-title'; + title.textContent = t.title || '–'; + + li.appendChild(idx); + li.appendChild(title); + li.addEventListener('click', () => { + send({ cmd: 'jump', index: parseInt(li.dataset.index, 10) }); + playlistOverlay.classList.add('hidden'); + }); + frag.appendChild(li); + }); + + playlistList.innerHTML = ''; + playlistList.appendChild(frag); + scrollToCurrentTrack(); +} + +function updatePlaylistHighlight() { + if (playlistOverlay.classList.contains('hidden')) return; + playlistList.querySelectorAll('li').forEach(li => { + const isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1; + li.classList.toggle('current', isCurrent); + }); + scrollToCurrentTrack(); +} + +function scrollToCurrentTrack() { + const current = playlistList.querySelector('li.current'); + if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' }); +} + // ── Boot ────────────────────────────────────────────────────────────────────── connect(); renderFrame(); diff --git a/web/static/icon.svg b/web/static/icon.svg new file mode 100644 index 0000000..2ce3ca5 --- /dev/null +++ b/web/static/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/static/index.html b/web/static/index.html index b3422b7..6e79094 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -5,6 +5,13 @@ roadamp + + + + + + +
    @@ -58,6 +65,7 @@
    +
    @@ -68,6 +76,20 @@
    + + + + diff --git a/web/static/manifest.json b/web/static/manifest.json new file mode 100644 index 0000000..fde7547 --- /dev/null +++ b/web/static/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "roadamp", + "short_name": "roadamp", + "description": "Winamp remote control", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/web/static/style.css b/web/static/style.css index 599167f..89d2d1b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -177,7 +177,8 @@ html, body { } .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; } +.btn-kill-list { width: 64px; height: 52px; font-size: 14px; } +.btn-action { width: 52px; height: 52px; font-size: 20px; } #killist-panel { background: var(--surface); @@ -205,4 +206,84 @@ html, body { } #btn-close-killist { margin-top: 12px; width: 100%; height: 44px; font-size: 15px; } +/* ── Playlist overlay ────────────────────────────────────────────────────── */ +#playlist-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: var(--bg); + display: flex; + flex-direction: column; +} +#playlist-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: var(--surface); + border-bottom: 1px solid #ffffff12; + flex-shrink: 0; +} +#playlist-title-label { + font-size: 16px; + font-weight: 600; +} +#btn-close-playlist { + background: none; + border: none; + color: var(--text-dim); + font-size: 22px; + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + line-height: 1; + touch-action: manipulation; +} +#btn-close-playlist:active { background: var(--accent2); color: var(--text); } + +#playlist-list { + list-style: none; + overflow-y: auto; + flex: 1; + padding: 8px 0; + -webkit-overflow-scrolling: touch; +} +#playlist-list li { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + cursor: pointer; + border-radius: 0; + transition: background 0.1s; + -webkit-tap-highlight-color: transparent; +} +#playlist-list li:active { background: var(--accent2); } +#playlist-list li.current { + background: #e9456018; + border-left: 3px solid var(--accent); + padding-left: 17px; +} +.pl-idx { + font-size: 12px; + color: var(--text-dim); + min-width: 32px; + text-align: right; + flex-shrink: 0; +} +.pl-title { + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +li.current .pl-title { color: var(--accent); font-weight: 600; } + +#playlist-loading { + text-align: center; + color: var(--text-dim); + padding: 40px; + font-size: 14px; +} + .hidden { display: none !important; } diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..24e2d0d --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,39 @@ +'use strict'; + +const CACHE = 'roadamp-v1'; +const SHELL = ['/', '/app.js', '/style.css', '/manifest.json', '/icon.svg']; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE) + .then(c => c.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + const url = new URL(e.request.url); + // Pass through API calls and WebSocket upgrades uncached. + if (url.pathname.startsWith('/api/') || url.pathname === '/ws') return; + + e.respondWith( + caches.match(e.request).then(cached => { + if (cached) return cached; + return fetch(e.request).then(res => { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + return res; + }); + }) + ); +});