Pārlūkot izejas kodu

fix: idle canvas animation, position overflow, title parsing

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>
master
Jan Svabenik pirms 1 mēnesi
vecāks
revīzija
79766be53f
2 mainītis faili ar 84 papildinājumiem un 51 dzēšanām
  1. +22
    -26
      internal/winamp/winamp.go
  2. +62
    -25
      web/static/app.js

+ 22
- 26
internal/winamp/winamp.go Parādīt failu

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

+ 62
- 25
web/static/app.js Parādīt failu

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


Notiek ielāde…
Atcelt
Saglabāt