diff --git a/go.mod b/go.mod index 0838658..368fa00 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module git.svabi.ch/jan/roadamp go 1.25.0 require ( + github.com/bogem/id3v2/v2 v2.1.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.3.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c51a036..0d5e365 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,34 @@ +github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= +github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/rating/rating.go b/internal/rating/rating.go new file mode 100644 index 0000000..e1fed5d --- /dev/null +++ b/internal/rating/rating.go @@ -0,0 +1,104 @@ +// Package rating reads and writes ID3v2 POPM (Popularimeter) star ratings +// for MP3 files using the scale shared by Winamp and Windows Explorer: +// +// 0 = unrated +// 1 = ★☆☆☆☆ (POPM byte 1) +// 2 = ★★☆☆☆ (POPM byte 64) +// 3 = ★★★☆☆ (POPM byte 128) +// 4 = ★★★★☆ (POPM byte 196) +// 5 = ★★★★★ (POPM byte 255) +// +// The email field is set to "rating@winamp.com" (Winamp's standard identifier). +// Windows Explorer reads all POPM frames regardless of the email field. +package rating + +import ( + "fmt" + "math/big" + "path/filepath" + "strings" + + id3 "github.com/bogem/id3v2/v2" +) + +const emailKey = "rating@winamp.com" + +// Get returns the 0–5 star rating stored in the POPM frame of path. +// Returns 0 (unrated) if the file has no POPM frame or is not an MP3. +func Get(path string) (int, error) { + if !isMP3(path) { + return 0, nil + } + tag, err := id3.Open(path, id3.Options{Parse: true, ParseFrames: []string{"POPM"}}) + if err != nil { + return 0, fmt.Errorf("rating.Get: %w", err) + } + defer tag.Close() + + for _, f := range tag.GetFrames("POPM") { + pf, ok := f.(id3.PopularimeterFrame) + if !ok { + continue + } + return popmToStars(pf.Rating), nil + } + return 0, nil +} + +// Set writes a 0–5 star rating into the POPM frame of path. +// stars=0 removes any existing POPM frame (unrated). +func Set(path string, stars int) error { + if !isMP3(path) { + return fmt.Errorf("rating.Set: not an MP3 file: %s", filepath.Base(path)) + } + if stars < 0 || stars > 5 { + return fmt.Errorf("rating.Set: stars must be 0–5, got %d", stars) + } + + tag, err := id3.Open(path, id3.Options{Parse: true}) + if err != nil { + return fmt.Errorf("rating.Set: %w", err) + } + defer tag.Close() + + tag.DeleteFrames("POPM") + if stars > 0 { + tag.AddFrame("POPM", id3.PopularimeterFrame{ + Email: emailKey, + Rating: starsToPOPM(stars), + Counter: big.NewInt(0), + }) + } + if err := tag.Save(); err != nil { + return fmt.Errorf("rating.Set: save %s: %w", filepath.Base(path), err) + } + return nil +} + +func isMP3(path string) bool { + return strings.EqualFold(filepath.Ext(path), ".mp3") +} + +// popmToStars maps a raw POPM byte to a 0–5 star rating using the +// Windows Explorer / Winamp read ranges. +func popmToStars(r uint8) int { + switch { + case r == 0: + return 0 + case r < 32: + return 1 + case r < 96: + return 2 + case r < 160: + return 3 + case r < 224: + return 4 + default: + return 5 + } +} + +// starsToPOPM returns the canonical POPM byte for a given star count. +func starsToPOPM(stars int) uint8 { + return [6]uint8{0, 1, 64, 128, 196, 255}[stars] +} diff --git a/internal/server/server.go b/internal/server/server.go index f87106d..d9d1b29 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "time" "git.svabi.ch/jan/roadamp/internal/killist" + "git.svabi.ch/jan/roadamp/internal/rating" "git.svabi.ch/jan/roadamp/internal/resume" "git.svabi.ch/jan/roadamp/internal/viz" "git.svabi.ch/jan/roadamp/internal/volume" @@ -110,6 +111,7 @@ func (s *Server) routes() { 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/rating", s.handleRating) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } @@ -451,6 +453,47 @@ func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.GetPlaylist()) } +func (s *Server) handleRating(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + path := s.wa.GetCurrentFile() + if path == "" { + jsonOK(w, map[string]int{"stars": 0}) + return + } + stars, err := rating.Get(path) + if err != nil { + log.Printf("rating.Get: %v", err) + jsonOK(w, map[string]int{"stars": 0}) + return + } + jsonOK(w, map[string]int{"stars": stars}) + + case http.MethodPost: + var req struct { + Stars int `json:"stars"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + path := s.wa.GetCurrentFile() + if path == "" { + http.Error(w, "no track playing", http.StatusConflict) + return + } + if err := rating.Set(path, req.Stars); err != nil { + log.Printf("rating.Set: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jsonOK(w, map[string]int{"stars": req.Stars}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + 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 a351848..8b947e5 100644 --- a/internal/winamp/winamp.go +++ b/internal/winamp/winamp.go @@ -52,6 +52,7 @@ const ( userGetPlaylistLen = 124 userSetPlaylistPos = 121 userGetPlaylistTitle = 212 + userGetPlaylistFile = 211 userRestart = 135 // OpenProcess access right @@ -335,3 +336,26 @@ func (c *Controller) JumpToTrack(zeroBasedIndex int) bool { send(h, wmCommand, cmdPlay, 0) return true } + +// GetCurrentFile returns the file system path of the currently playing track. +// It reads the path from Winamp's process memory via IPC_GETPLAYLISTFILE. +func (c *Controller) GetCurrentFile() string { + h := c.handle() + if h == 0 { + return "" + } + // userGetPlaylistPos returns 0-based current index + pos := send(h, wmUser, 0, userGetPlaylistPos) + ptr := send(h, wmUser, pos, userGetPlaylistFile) + if ptr == 0 { + return "" + } + var pid uint32 + getWindowThreadProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid))) + proc, _, _ := openProcess.Call(processVMRead, 0, uintptr(pid)) + if proc == 0 { + return "" + } + defer syscall.CloseHandle(syscall.Handle(proc)) + return readRemoteString(syscall.Handle(proc), ptr) +} diff --git a/web/static/app.js b/web/static/app.js index 87fbcaa..604be97 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -22,8 +22,10 @@ const canvas = $('viz'); const ctx2d = canvas.getContext('2d'); // ── State ───────────────────────────────────────────────────────────────────── -let currentVolume = 50; -let currentPlaylistPos = 0; // 1-based, updated from status +let currentVolume = 50; +let currentPlaylistPos = 0; // 1-based, updated from status +let currentRating = -1; // -1 = not yet loaded +let lastRatedTitle = ''; // track we last fetched rating for let ws = null; let reconnectTimer = null; @@ -91,6 +93,17 @@ function applyStatus(st) { stateLabel.textContent = stateMap[st.state] ?? st.state; trackTitle.textContent = st.title || '–'; + + // Fetch rating whenever the track changes + if (st.title && st.title !== lastRatedTitle) { + lastRatedTitle = st.title; + fetchRating(); + } + if (!st.title || !st.running) { + lastRatedTitle = ''; + renderStars(0); + } + playlistPos.textContent = st.playlist_length ? `${st.playlist_pos} / ${st.playlist_length}` : ''; @@ -182,6 +195,48 @@ function updateMuteBtn(muted) { btnMute.classList.toggle('muted', muted); } +// ── Rating (stars) ──────────────────────────────────────────────────────────── +const starEls = document.querySelectorAll('.star'); + +async function fetchRating() { + try { + const r = await fetch('/api/rating').then(r => r.json()); + currentRating = r.stars ?? 0; + renderStars(currentRating); + } catch { + renderStars(0); + } +} + +function renderStars(n) { + currentRating = n; + starEls.forEach(s => { + const v = parseInt(s.dataset.v, 10); + const lit = v <= n; + s.textContent = lit ? '★' : '☆'; + s.classList.toggle('lit', lit); + }); +} + +starEls.forEach(s => { + s.addEventListener('click', async () => { + const v = parseInt(s.dataset.v, 10); + // Tap the current rating again → remove it (set to 0) + const newRating = v === currentRating ? 0 : v; + renderStars(newRating); + try { + await fetch('/api/rating', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stars: newRating }), + }); + } catch { + // Revert on error + renderStars(currentRating); + } + }); +}); + // ── KillList ────────────────────────────────────────────────────────────────── $('btn-kill').addEventListener('click', () => { send({ cmd: 'killist_add' }); diff --git a/web/static/index.html b/web/static/index.html index 6e79094..783dd97 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -22,6 +22,13 @@
Nicht verbunden
+
+ + + + + +
diff --git a/web/static/style.css b/web/static/style.css index c3862a4..06ed11f 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -56,10 +56,26 @@ html, body { overflow: hidden; text-overflow: ellipsis; } +#star-rating { + font-size: 32px; + margin-top: 10px; + letter-spacing: 4px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; +} +.star { + color: #333; + transition: color 0.12s, transform 0.1s; + display: inline-block; +} +.star.lit { color: #f5a623; } +.star:active { transform: scale(1.25); } + #playlist-pos { font-size: 12px; color: var(--text-dim); - margin-top: 4px; + margin-top: 6px; } /* Visualisation canvas */