diff --git a/internal/winamp/winamp.go b/internal/winamp/winamp.go index 6e7662a..e7c786a 100644 --- a/internal/winamp/winamp.go +++ b/internal/winamp/winamp.go @@ -7,6 +7,7 @@ package winamp import ( "fmt" + "strings" "syscall" "unsafe" ) @@ -109,9 +110,10 @@ func (c *Controller) IsPaused() bool { return c.PlayState() == 3 } func (c *Controller) IsStopped() bool { return c.PlayState() == 0 } // GetPosition returns current playback offset in seconds. +// Returns 0 when stopped (Winamp returns 0xFFFFFFFF in that state). func (c *Controller) GetPosition() int { v, ok := c.user(0, userGetPosition) - if !ok { + if !ok || v > 0xF0000000 { // 0xFFFFFFFF = stopped/no track return 0 } return int(v) / 1000 @@ -184,7 +186,14 @@ func (c *Controller) GetVersion() string { } // GetTitle returns the title of the currently playing track by reading -// the Winamp window title (format: "N. Artist - Track - Winamp"). +// the Winamp window title. +// +// Winamp 5.x formats the window title as one of: +// +// "N. Artist - Title - Winamp" (playing) +// "Winamp" (stopped, no playlist) +// +// We strip the " - Winamp" suffix and the leading "N. " playlist prefix. func (c *Controller) GetTitle() string { h := c.handle() if h == 0 { @@ -194,33 +203,20 @@ func (c *Controller) GetTitle() string { getWindowTextW.Call(uintptr(h), uintptr(unsafe.Pointer(&buf[0])), 512) title := syscall.UTF16ToString(buf) - // Strip trailing " - Winamp" suffix + // Strip " - Winamp" suffix (use last occurrence so track titles + // containing " - Winamp" are handled correctly). const suffix = " - Winamp" - if idx := lastIndex(title, suffix); idx >= 0 { + if idx := strings.LastIndex(title, suffix); idx >= 0 { title = title[:idx] + } else { + // Title is just "Winamp" (stopped, empty playlist). + return "" } - // Strip leading playlist-number prefix "NNN. " - if idx := indexOf(title, ". "); idx >= 0 { - title = title[idx+2:] - } - return title -} - -func lastIndex(s, sub string) int { - last := -1 - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - last = i - } - } - return last -} -func indexOf(s, sub string) int { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return i - } + // Strip leading playlist-number prefix: digits followed by ". " + // e.g. "4. " or "12. " + if dot := strings.Index(title, ". "); dot >= 0 && dot <= 4 { + title = title[dot+2:] } - return -1 + return title } diff --git a/web/static/app.js b/web/static/app.js index b8d420f..878ed2f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -25,10 +25,12 @@ let ws = null; let reconnectTimer = null; // Viz state -const NUM_BARS = 64; -const peaks = new Float32Array(NUM_BARS); -let lastBars = new Float32Array(NUM_BARS); -let rafId = null; +const NUM_BARS = 64; +const peaks = new Float32Array(NUM_BARS); +let lastBars = new Float32Array(NUM_BARS); +let rafId = null; +let lastVizAt = 0; // timestamp of last received viz frame +let winampPlaying = false; // ── WebSocket ───────────────────────────────────────────────────────────────── function connect() { @@ -94,6 +96,7 @@ function applyStatus(st) { } btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; + winampPlaying = st.state === 'playing'; if (typeof st.volume === 'number') { currentVolume = st.volume; @@ -193,54 +196,88 @@ $('btn-close-killist').addEventListener('click', () => function applyViz(bars) { if (!bars || bars.length === 0) return; lastBars = new Float32Array(bars); + lastVizAt = performance.now(); } -function renderFrame() { +function renderFrame(ts = 0) { rafId = requestAnimationFrame(renderFrame); - // Resize canvas to CSS size (handles window resize / DPR) - const dpr = window.devicePixelRatio || 1; + // Resize canvas to CSS size (handles window resize / DPR). + // Setting canvas.width resets the transform, so we re-apply scale. + const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth; const cssH = canvas.clientHeight; - if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { - canvas.width = cssW * dpr; - canvas.height = cssH * dpr; + if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) { + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); ctx2d.scale(dpr, dpr); } const w = cssW; const h = cssH; - const n = lastBars.length || NUM_BARS; + if (w === 0 || h === 0) return; + + // Check if real viz data is fresh (< 1.5 s old) and Winamp is playing. + const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500; // Background ctx2d.fillStyle = '#000'; ctx2d.fillRect(0, 0, w, h); + const n = NUM_BARS; const gap = 1; - const barW = (w - gap * (n - 1)) / n; + const barW = Math.max(1, (w - gap * (n - 1)) / n); for (let i = 0; i < n; i++) { - const val = lastBars[i] || 0; - - // Peak: fast rise, slow fall (2% per frame) - if (val > peaks[i]) peaks[i] = val; - else peaks[i] = Math.max(0, peaks[i] - 0.012); + let val; + + if (hasSignal) { + // Real spectrum data + val = lastBars[i] || 0; + if (val > peaks[i]) peaks[i] = val; + else peaks[i] = Math.max(0, peaks[i] - 0.012); + } else { + // Idle animation: slow sine "breathing" across the bars. + // Amplitude fades out when paused/stopped. + const phase = (ts / 1800) + (i / n) * Math.PI * 2; + const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; // 0..0.08 + val = Math.max(0, Math.sin(phase) * breath); + peaks[i] = Math.max(0, peaks[i] - 0.02); // let peaks fall quickly + } - const x = i * (barW + gap); + const x = Math.round(i * (barW + gap)); const barH = val * h; - // Bar colour: green (120°) → yellow (60°) → red (0°) based on amplitude - const hue = Math.round((1 - val) * 120); - ctx2d.fillStyle = `hsl(${hue},100%,42%)`; - ctx2d.fillRect(x, h - barH, barW, barH); + if (barH > 0.5) { + // Bar colour: green (120°) → yellow (60°) → red (0°) + const hue = Math.round((1 - val) * 120); + const lit = hasSignal ? 42 : 25; // dimmer in idle + ctx2d.fillStyle = `hsl(${hue},100%,${lit}%)`; + ctx2d.fillRect(x, h - barH, barW, barH); + } - // Peak indicator — thin white line + // Peak indicator if (peaks[i] > 0.02) { - const py = h - peaks[i] * h - 1; - ctx2d.fillStyle = 'rgba(255,255,255,0.75)'; + const py = Math.round(h - peaks[i] * h) - 1; + ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.2)'; ctx2d.fillRect(x, py, barW, 2); } } + + // "No signal" label when disconnected or stopped + if (!ws || ws.readyState !== WebSocket.OPEN) { + drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333'); + } else if (!winampPlaying) { + drawLabel(ctx2d, w, h, '▶ PLAY', '#1a3a1a'); + } +} + +function drawLabel(ctx, w, h, text, color) { + ctx.font = `bold ${Math.round(h * 0.22)}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color; + ctx.fillText(text, w / 2, h / 2); } // ── Helpers ───────────────────────────────────────────────────────────────────