Right-hand time on the playback bar now displays the full track length
(e.g. 1:00:00) rather than the countdown (-49:39). Updated the progress-bar
click-to-seek handler to read total directly from that label instead of
deriving it from current + remaining. The canvas remaining-time display
mode is untouched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-align:center had no effect because display:flex makes the button text
an anonymous flex item, centered only by justify-content -- which is the
exact property broken for <button> on iOS 9. Removed flexbox from .btn
entirely and switched to text-align (horizontal) + per-variant line-height
(vertical). Single-line only, but reliable on every browser including iOS 9.
Also bumped legacy symbol sizes per feedback: <</>> 30px, ■/► 40px,
vol/mut 18px, PL 20px.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two iPad 2 issues from screenshot:
1. Button text was left-aligned. On iOS 9 a <button> with display:flex
ignores justify-content:center. Added text-align:center to .btn as a
fallback (harmless when flex works).
2. Play symbol rendered as broken-glyph tofu. ▶ (U+25B6) gets emoji
presentation which fails on iOS 9. Switched legacy play to ► (U+25BA),
a text-presentation pointer in the same Geometric Shapes block as the
■ stop glyph that already renders fine. Canvas PLAY label now uses
SYM.play too.
Modern browsers unaffected (still ▶ emoji, flex centering).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In ECMAScript 5 strict mode, function declarations inside blocks
(if/for/while) are a syntax error. Since app.js uses 'use strict',
the legacySym function defined inside the if(isLegacyIOS) block could
prevent the entire script from parsing, killing connect() on all browsers.
Moved legacySym() to module scope. The if-block now only contains
the call sites, which is valid everywhere.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-char symbols (<< >>) get 26px bold, single-char (■ ▶) get 36px bold.
letter-spacing: 2px spreads the chars slightly for better visual weight.
Mute button (vol/mut) gets 16px bold to stay readable in its 48px container.
Flex centering already guaranteed by .btn layout -- no extra work needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Media-control emoji (U+23EE U+23F8 U+23F9 U+23ED) and 🔊 🔇 📋 🚫
are absent from the iOS 9 system font and render as empty boxes on iPad 2.
Added isLegacyIOS detection (iOS <= 9 via UA) and a SYM symbol table
that picks between emoji (modern) and plain-text fallbacks for iOS 9:
prev=<< pause=|| stop=■ next=>> volOn=vol volOff=mut playlist=PL skip=✕
Static buttons replaced at boot; dynamic updates (play/pause, mute)
use SYM throughout. Modern browsers completely unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NodeList.prototype.forEach was added in Safari 10 / iOS 10.
On iOS 9 it throws 'TypeError: forEach is not a function' at
init time, before connect() is called — leaving the app stuck
on 'Nicht verbunden' despite the async/await fix.
Added qsa(selector, root?) helper that wraps querySelectorAll
in Array.prototype.slice.call() to produce a real Array, then
replaced all three call sites:
- document.querySelectorAll('.btn-seek').forEach
- starEls (stored NodeList).forEach x2
- playlistList.querySelectorAll('li').forEach
Array.prototype.forEach has been safe since iOS 4.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
async/await, catch{} without binding, and ?? are all syntax errors in
Safari 9 (iOS 9). A single syntax error prevents the entire script from
parsing, so connect() never ran — explaining 'nicht verbunden'.
Removed / replaced every incompatible construct:
- async/await → .then()/.catch() Promise chains
- catch {} → catch(_e) {} (optional catch binding is ES2019)
- ?? → explicit != null ternary (nullish coalescing is ES2020)
- padStart() → pad2() helper (String.prototype.padStart is ES2017,
not available in iOS < 10)
- scrollIntoView({block,behavior}) → scrollIntoView(true) (options
object not supported in iOS 9, boolean form works everywhere)
Template literals, arrow functions, const/let, forEach, classList,
dataset, Promise, WebSocket, Canvas, performance.now() and
requestAnimationFrame are all fine in iOS 9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CSS:
- #seek-row and #controls-row: flexbox base (flex: 1 on children,
margin-left gaps) + CSS Grid enhancement via @supports (display: grid)
- #playlist-overlay: replaced inset: 0 with explicit top/right/bottom/left
(inset shorthand not available before iOS 14.5)
- All flex-gap usages: adjacent-sibling margin fallbacks as the base;
gap values restored via a single @supports (gap: 1px) block at the end
(flex gap not available before iOS 14.5 / Safari 14.1)
- Added -webkit- prefixes for user-select, flex, transition, transform
throughout to be safe on older WebKit
JS:
- Added apiFetch() wrapper: uses native fetch() when available (iOS 10.3+),
falls back to a minimal XMLHttpRequest shim for iOS 9 and older.
Matches the exact subset of the fetch API the app uses: .json(),
method, headers, body.
- Replaced all four fetch() call sites with apiFetch()
Result: layout and all API calls (rating, killist, playlist) work on
iOS 9 / iPad 2. Modern browsers get the exact same behaviour as before
via the @supports enhancements.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous Set() always did a full tag+audio rewrite, even for changing
a single byte. That triggered the FILE_SHARE_DELETE rename dance and
required streaming kilobytes through a temp file just to flip a rating.
New strategy (tryPatchPOPM in popm.go):
- Existing POPM frame: seek + 1 byte write. Done.
- No POPM, padding available: write a fresh ~33-byte POPM frame into
the existing zero-padding. Tag size unchanged, audio offset unchanged,
no rename, works while Winamp holds the file open.
- Anything weird (no ID3 tag, header flags set, unknown version,
padding too small, malformed frame, POPM with frame flags): refuse
with errPatchRefused; Set() falls back to the existing rewrite path.
Safety invariants enforced before every write:
- Strict bounds: write region must lie inside [10, 10+tagSize).
- Padding region must be verified all-zero.
- Only ID3v2.3 and v2.4 with zero header flags accepted.
- POPM frame must have zero frame flags (rejects compression /
encryption / data-length indicator that would shift the rating
offset).
- All frame sizes are validated against tag bounds before use.
Tests (popm_test.go): patch existing POPM for both v2.3 and v2.4,
insert into padding, no-op when stars=0 and no frame, walk past TIT2
to find POPM, plus seven refusal cases (no ID3, unsync flag, unknown
version, small padding, POPM with flags, invalid frame ID, oversized
frame size). Also: integration test that Set() preserves file size
when padding is available, proving the patch path is taken.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation used tag.WriteTo() which only emits the ID3
tag (~311 bytes), leaving audio data behind. Files were truncated to just
the tag on every rating write.
New strategy:
1. Parse the 10-byte ID3v2 header to find the audio start offset.
2. Encode the new tag into an in-memory buffer via WriteTo.
3. Write tag + original audio into a temp file.
4. Try atomic os.Rename (works when Winamp does not hold the file).
5. Fall back to direct O_WRONLY|O_TRUNC write (works while Winamp plays,
because Winamp opens with FILE_SHARE_WRITE on Windows).
Tests cover: POPM<->stars mapping, id3v2AudioStart, full round-trip
(1-5 stars + unrate) with audio-integrity check, and error cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bogem/id3v2 Save() renames a temp file over the original, which Windows
denies when Winamp holds the file open (no FILE_SHARE_DELETE). Fix:
- Use tag.WriteTo(&buf) to encode tag+audio into memory while the read
handle is still open
- Close the read handle
- Reopen the original file with O_WRONLY|O_TRUNC and write the buffer
Winamp opens MP3s with FILE_SHARE_WRITE so the in-place overwrite succeeds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- winamp.GetCurrentFile() reads file path via IPC_GETPLAYLISTFILE (211)
+ ReadProcessMemory, same pattern as playlist titles
- internal/rating: Get/Set POPM frame via bogem/id3v2
- Email: rating@winamp.com (Winamp standard)
- Byte scale: 0/1/64/128/196/255 = 0-5 stars
- Compatible with Windows Explorer and Winamp
- GET /api/rating -> {stars: N}
- POST /api/rating {stars: N} -> writes POPM, returns {stars: N}
Frontend:
- 5 stars in track-info, gold when lit
- Fetched automatically on track change
- Tap to rate; tap same star again to remove rating
- Optimistic update with revert on error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace per-frame hsl(val*120) with a static createLinearGradient
(green->yellow->red, bottom to top) shared across all bars. Colour is
now position-based, not amplitude-based, so it never flickers. Peak
decay slightly slower (0.008/frame) for smoother hold.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three modes on canvas click:
viz -> spectrum analyser (default)
actual -> hh:mm:ss elapsed in white, ELAPSED label
remaining -> -hh:mm:ss countdown in accent red, REMAINING label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Time display now shows elapsed / -remaining instead of elapsed / total,
matching the Delphi original (reversetime mode). Progress-bar click seek
derives total from current + remaining to stay correct.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
os.Create truncates the target immediately; a failed write or flush
leaves the file empty/corrupt. Write to a temp file in the same
directory, flush, close, then os.Rename — the rename is atomic on
Windows (same volume). Also check WriteString return values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sw.js uses __BUILDVER__ token; build.ps1 replaces it with the git
version before go build (which embeds the patched file), then restores
the template via the finally block. Installed PWA clients receive new
assets after every release.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Winamp IPC_GETPLAYLISTTITLE returns ANSI (char*) strings in its process
memory. Blindly casting bytes to string garbles umlauts and other non-ASCII
chars. Fix: decode via MultiByteToWideChar(CP_ACP) → UTF-16 → Go string,
using the same code page Windows uses for the title.
Also check ReadProcessMemory return value — failures now return empty
string instead of garbage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract saveResumeState helper: saves when playing OR paused (not just
paused). Both WS and REST stop handlers now call it.
- restoreResume now jumps to saved PlaylistPos before seeking, so the
correct track is loaded even when playlist order differs.
- Title validation: if the loaded title does not match the window title
after jumping, abort restore and delete stale resume file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
runtime.LockOSThread/UnlockOSThread added to withEndpointVolume (volume)
and Capturer.run (viz). COM apartments are thread-affine on Windows;
without the lock the Go scheduler can migrate a goroutine mid-call to a
thread that never called CoInitializeEx, causing sporadic failures.
Also check CoInitializeEx HRESULT: S_OK (0) and S_FALSE (1) are success,
anything else is a real error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- Add host field to Config (default 0.0.0.0)
- Change default port from 8080 to 8889
- Listen on host:port instead of :port
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add build.ps1: one-shot PowerShell build with version from git tag
- winamp_path config is now optional; roadamp works with any running
Winamp instance regardless of install location (FindWindow does the
discovery). Path is only needed if roadamp should launch Winamp itself.
- Remove hardcoded C:\Program Files\Winamp default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Static files are now compiled into the binary using //go:embed.
- Add web/embed.go with //go:embed static directive
- Update server.New() to accept fs.FS parameter
- Switch file server from http.Dir to http.FS
Deployment is now: roadamp.exe + config.yaml + winamp/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Canvas / viz:
- Idle animation when Winamp stopped/paused: slow sine-wave breathing
across bars at low amplitude, dim colours — canvas is never dead-black
- 'PLAY' label overlay when stopped, 'NO SIGNAL' when WS disconnected
- hasSignal check: viz data must be < 1.5s old AND Winamp playing
- DPR resize uses Math.round() to avoid sub-pixel canvas size mismatch
- Peak indicators fade quickly in idle mode
winamp.go:
- GetPosition: clamp 0xFFFFFFFF sentinel at source (> 0xF0000000 → 0)
instead of in server.go; also removed redundant clamp from statusMsg
- GetTitle: use strings.LastIndex/Index instead of hand-rolled helpers
Returns empty string when window title is just 'Winamp' (stopped/empty)
Playlist prefix strip is now bounded (dot <= 4) so track titles with
'. ' in them are not accidentally trimmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- viz/capture.go: waveFormatExtensibleEx now flat struct (not embedded)
Go pads waveFormatEx to 20 bytes (uint32 alignment), but the Windows
C struct is 18 bytes — embedding caused SubFormat to land at wrong
offset, breaking float32 detection (float=false bug)
- server.go: clamp position/length values > 24h to 0
Winamp returns 0xFFFFFFFF for these fields when stopped/no track loaded
- .gitignore: exclude winamp/ dir and runtime .dat files
- config.yaml: added (gitignored) pointing to winamp/winamp.exe
Tested: Winamp 5.9 portable in winamp/, IPC connection verified,
viz loopback correctly detects 48kHz float32 stereo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WebSocket (/ws):
- Replaces 2s HTTP polling with persistent WS connection
- Server pushes status updates every 500ms to all clients
- Server pushes FFT spectrum frames at ~30fps
- Bidirectional: client sends commands as JSON ({cmd, delta, level, ...})
- Auto-reconnect on disconnect (3s backoff)
- Status pushed immediately on connect
Visualisation (internal/viz/capture.go):
- WASAPI loopback capture via IAudioClient (same COM approach as volume)
- Captures whatever is playing through the default render device
- 2048-sample Hanning-windowed FFT (pure Go, no deps)
- 64 log-spaced bars, 40Hz-20kHz
- Fast attack / slow decay smoothing per bar
Canvas renderer (app.js):
- requestAnimationFrame loop, DPR-aware resize
- Green->yellow->red HSL gradient by amplitude
- Peak-hold indicators with 1.2%/frame decay
- Graceful: canvas stays dark if no viz data (Winamp paused/stopped)
Architecture:
- internal/server/hub.go: gorilla/websocket hub (register/broadcast/unregister)
- internal/server/server.go: full //go:build windows, REST API kept for debug
- frontend uses WS for all commands, REST only for killist list fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- internal/volume: IAudioEndpointVolume via COM (pure Go, no CGO)
- Get/Set master volume (0-100%)
- GetMute/SetMute
- Uses WASAPI instead of deprecated MMSystem mixer API
- server: /api/volume now uses system volume (0-100, was Winamp 0-255)
- server: new /api/mute endpoint (GET + POST ?muted=true|false)
- server: volume+mute included in /api/status response
- frontend: mute button with visual state (🔊/🔇, red bar when muted)
- frontend: volume display as percentage label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>