Sfoglia il codice sorgente

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 <noreply@anthropic.com>
master
Jan Svabenik 1 mese fa
parent
commit
49a22b8f36
8 ha cambiato i file con 359 aggiunte e 29 eliminazioni
  1. +8
    -0
      internal/server/server.go
  2. +94
    -12
      internal/winamp/winamp.go
  3. +89
    -16
      web/static/app.js
  4. +8
    -0
      web/static/icon.svg
  5. +22
    -0
      web/static/index.html
  6. +17
    -0
      web/static/manifest.json
  7. +82
    -1
      web/static/style.css
  8. +39
    -0
      web/static/sw.js

+ 8
- 0
internal/server/server.go Vedi File

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


+ 94
- 12
internal/winamp/winamp.go Vedi File

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

+ 89
- 16
web/static/app.js Vedi File

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

+ 8
- 0
web/static/icon.svg Vedi File

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

+ 22
- 0
web/static/index.html Vedi File

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

+ 17
- 0
web/static/manifest.json Vedi File

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

+ 82
- 1
web/static/style.css Vedi File

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

+ 39
- 0
web/static/sw.js Vedi File

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

Loading…
Annulla
Salva