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