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 <noreply@anthropic.com>
master
| @@ -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"}) | |||
| @@ -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 | |||
| } | |||
| @@ -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 = '<li id="playlist-loading">Lade…</li>'; | |||
| let tracks; | |||
| try { | |||
| tracks = await fetch('/api/playlist').then(r => r.json()); | |||
| } catch { | |||
| playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>'; | |||
| return; | |||
| } | |||
| if (!tracks || tracks.length === 0) { | |||
| playlistList.innerHTML = '<li id="playlist-loading">Playlist leer</li>'; | |||
| 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(); | |||
| @@ -0,0 +1,8 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> | |||
| <rect width="512" height="512" rx="112" fill="#1a1a2e"/> | |||
| <rect x="160" y="320" width="48" height="48" rx="24" fill="#e94560"/> | |||
| <rect x="304" y="272" width="48" height="48" rx="24" fill="#e94560"/> | |||
| <rect x="208" y="152" width="32" height="200" rx="8" fill="#e94560"/> | |||
| <rect x="352" y="104" width="32" height="200" rx="8" fill="#e94560"/> | |||
| <rect x="208" y="152" width="176" height="32" rx="8" fill="#e94560"/> | |||
| </svg> | |||
| @@ -5,6 +5,13 @@ | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> | |||
| <title>roadamp</title> | |||
| <link rel="stylesheet" href="style.css" /> | |||
| <link rel="manifest" href="/manifest.json" /> | |||
| <meta name="theme-color" content="#1a1a2e" /> | |||
| <meta name="mobile-web-app-capable" content="yes" /> | |||
| <meta name="apple-mobile-web-app-capable" content="yes" /> | |||
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> | |||
| <meta name="apple-mobile-web-app-title" content="roadamp" /> | |||
| <link rel="apple-touch-icon" href="/icon.svg" /> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| @@ -58,6 +65,7 @@ | |||
| <div id="killist-row"> | |||
| <button class="btn btn-kill" id="btn-kill">🚫 Überspringen</button> | |||
| <button class="btn btn-action" id="btn-show-playlist">📋</button> | |||
| <button class="btn btn-kill-list" id="btn-show-killist">Liste</button> | |||
| </div> | |||
| @@ -68,6 +76,20 @@ | |||
| </div> | |||
| </div> | |||
| <!-- Playlist overlay --> | |||
| <div id="playlist-overlay" class="hidden"> | |||
| <div id="playlist-header"> | |||
| <span id="playlist-title-label">Playlist</span> | |||
| <button id="btn-close-playlist">✕</button> | |||
| </div> | |||
| <ul id="playlist-list"></ul> | |||
| </div> | |||
| <script src="app.js"></script> | |||
| <script> | |||
| if ('serviceWorker' in navigator) { | |||
| navigator.serviceWorker.register('/sw.js').catch(() => {}); | |||
| } | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -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" | |||
| } | |||
| ] | |||
| } | |||
| @@ -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; } | |||
| @@ -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; | |||
| }); | |||
| }) | |||
| ); | |||
| }); | |||