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/volume", s.handleVolume) | ||||
| s.mux.HandleFunc("/api/mute", s.handleMute) | s.mux.HandleFunc("/api/mute", s.handleMute) | ||||
| s.mux.HandleFunc("/api/killist", s.handleKillist) | s.mux.HandleFunc("/api/killist", s.handleKillist) | ||||
| s.mux.HandleFunc("/api/playlist", s.handlePlaylist) | |||||
| s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | ||||
| } | } | ||||
| @@ -185,6 +186,7 @@ type wsCommand struct { | |||||
| Level int `json:"level"` | Level int `json:"level"` | ||||
| Muted bool `json:"muted"` | Muted bool `json:"muted"` | ||||
| Title string `json:"title"` | Title string `json:"title"` | ||||
| Index int `json:"index"` // 0-based track index for "jump" | |||||
| } | } | ||||
| func (s *Server) handleCommand(raw []byte) { | func (s *Server) handleCommand(raw []byte) { | ||||
| @@ -225,6 +227,8 @@ func (s *Server) handleCommand(raw []byte) { | |||||
| if title := s.wa.GetTitle(); title != "" { | if title := s.wa.GetTitle(); title != "" { | ||||
| _ = s.kl.Add(title) | _ = s.kl.Add(title) | ||||
| } | } | ||||
| case "jump": | |||||
| s.wa.JumpToTrack(cmd.Index) | |||||
| case "killist_remove": | case "killist_remove": | ||||
| _ = s.kl.Remove(cmd.Title) | _ = s.kl.Remove(cmd.Title) | ||||
| case "winamp_start": | 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) { | func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { | ||||
| if s.wa.IsRunning() { | if s.wa.IsRunning() { | ||||
| jsonOK(w, map[string]string{"status": "already_running"}) | jsonOK(w, map[string]string{"status": "already_running"}) | ||||
| @@ -13,10 +13,14 @@ import ( | |||||
| ) | ) | ||||
| var ( | 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 ( | const ( | ||||
| @@ -38,14 +42,19 @@ const ( | |||||
| cmdVolumeDown = 40059 | cmdVolumeDown = 40059 | ||||
| // Winamp WM_USER lParam IDs | // 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. | // Controller talks to a running Winamp instance. | ||||
| @@ -220,3 +229,76 @@ func (c *Controller) GetTitle() string { | |||||
| } | } | ||||
| return title | 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 ────────────────────────────────────────────────────────────────── | // ── DOM refs ────────────────────────────────────────────────────────────────── | ||||
| const $ = id => document.getElementById(id); | 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 ───────────────────────────────────────────────────────────────────── | // ── State ───────────────────────────────────────────────────────────────────── | ||||
| let currentVolume = 50; | |||||
| let currentVolume = 50; | |||||
| let currentPlaylistPos = 0; // 1-based, updated from status | |||||
| let ws = null; | let ws = null; | ||||
| let reconnectTimer = null; | let reconnectTimer = null; | ||||
| @@ -85,6 +88,11 @@ function applyStatus(st) { | |||||
| playlistPos.textContent = st.playlist_length | playlistPos.textContent = st.playlist_length | ||||
| ? `${st.playlist_pos} / ${st.playlist_length}` : ''; | ? `${st.playlist_pos} / ${st.playlist_length}` : ''; | ||||
| if (st.playlist_pos !== currentPlaylistPos) { | |||||
| currentPlaylistPos = st.playlist_pos; | |||||
| updatePlaylistHighlight(); | |||||
| } | |||||
| if (st.length > 0) { | if (st.length > 0) { | ||||
| progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; | progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; | ||||
| timeCurrent.textContent = fmtTime(st.position); | timeCurrent.textContent = fmtTime(st.position); | ||||
| @@ -316,6 +324,71 @@ function showToast(msg) { | |||||
| toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500); | 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 ────────────────────────────────────────────────────────────────────── | // ── Boot ────────────────────────────────────────────────────────────────────── | ||||
| connect(); | connect(); | ||||
| renderFrame(); | 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" /> | <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> | ||||
| <title>roadamp</title> | <title>roadamp</title> | ||||
| <link rel="stylesheet" href="style.css" /> | <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> | </head> | ||||
| <body> | <body> | ||||
| <div id="app"> | <div id="app"> | ||||
| @@ -58,6 +65,7 @@ | |||||
| <div id="killist-row"> | <div id="killist-row"> | ||||
| <button class="btn btn-kill" id="btn-kill">🚫 Überspringen</button> | <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> | <button class="btn btn-kill-list" id="btn-show-killist">Liste</button> | ||||
| </div> | </div> | ||||
| @@ -68,6 +76,20 @@ | |||||
| </div> | </div> | ||||
| </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 src="app.js"></script> | ||||
| <script> | |||||
| if ('serviceWorker' in navigator) { | |||||
| navigator.serviceWorker.register('/sw.js').catch(() => {}); | |||||
| } | |||||
| </script> | |||||
| </body> | </body> | ||||
| </html> | </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 { flex: 1; height: 52px; font-size: 16px; background: #3a1a1a; } | ||||
| .btn-kill:active { background: var(--accent); } | .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 { | #killist-panel { | ||||
| background: var(--surface); | background: var(--surface); | ||||
| @@ -205,4 +206,84 @@ html, body { | |||||
| } | } | ||||
| #btn-close-killist { margin-top: 12px; width: 100%; height: 44px; font-size: 15px; } | #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; } | .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; | |||||
| }); | |||||
| }) | |||||
| ); | |||||
| }); | |||||