'use strict'; // ── 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'); // ── State ───────────────────────────────────────────────────────────────────── let currentVolume = 50; 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; let lastVizAt = 0; // timestamp of last received viz frame let winampPlaying = false; // ── WebSocket ───────────────────────────────────────────────────────────────── function connect() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.addEventListener('open', () => { statusDot.className = 'ok'; stateLabel.textContent = 'Verbunden'; clearTimeout(reconnectTimer); }); ws.addEventListener('close', () => { statusDot.className = 'err'; stateLabel.textContent = 'Verbindung unterbrochen…'; ws = null; reconnectTimer = setTimeout(connect, 3000); }); ws.addEventListener('error', () => ws.close()); ws.addEventListener('message', e => { let msg; try { msg = JSON.parse(e.data); } catch { return; } if (msg.type === 'status') applyStatus(msg); if (msg.type === 'viz') applyViz(msg.bars); }); } function send(obj) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(obj)); } } // ── Status handler ──────────────────────────────────────────────────────────── function applyStatus(st) { if (!st.running) { statusDot.className = 'err'; stateLabel.textContent = 'Winamp nicht gestartet'; trackTitle.textContent = '–'; playlistPos.textContent = ''; return; } statusDot.className = 'ok'; const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; stateLabel.textContent = stateMap[st.state] ?? st.state; trackTitle.textContent = st.title || '–'; playlistPos.textContent = st.playlist_length ? `${st.playlist_pos} / ${st.playlist_length}` : ''; if (st.length > 0) { progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; timeCurrent.textContent = fmtTime(st.position); timeLength.textContent = fmtTime(st.length); } else { progressFill.style.width = '0%'; timeCurrent.textContent = '0:00'; timeLength.textContent = '0:00'; } btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; winampPlaying = st.state === 'playing'; if (typeof st.volume === 'number') { currentVolume = st.volume; updateVolumeFill(st.muted); updateMuteBtn(st.muted); } } // ── Controls ────────────────────────────────────────────────────────────────── btnPlay.addEventListener('click', () => { // Optimistic toggle — server will push the real state back immediately. const playing = btnPlay.textContent === '⏸'; send({ cmd: playing ? 'pause' : 'play' }); }); $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' })); $('btn-next').addEventListener('click', () => send({ cmd: 'next' })); $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' })); document.querySelectorAll('.btn-seek').forEach(btn => { btn.addEventListener('click', () => send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) })); }); $('progress-bar').addEventListener('click', async e => { // We need current length — read from last status (stored in DOM for now via timeLength). const total = parseTime(timeLength.textContent); if (!total) return; const rect = e.currentTarget.getBoundingClientRect(); const target = Math.round((e.clientX - rect.left) / rect.width * total); const current = parseTime(timeCurrent.textContent); send({ cmd: 'seek', delta: target - current }); }); // ── Volume ──────────────────────────────────────────────────────────────────── $('btn-vol-up').addEventListener('click', () => { currentVolume = Math.min(100, currentVolume + 5); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); $('btn-vol-down').addEventListener('click', () => { currentVolume = Math.max(0, currentVolume - 5); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); $('volume-bar').addEventListener('click', e => { const rect = e.currentTarget.getBoundingClientRect(); currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); btnMute.addEventListener('click', () => { const nowMuted = btnMute.classList.contains('muted'); send({ cmd: 'mute', muted: !nowMuted }); updateMuteBtn(!nowMuted); updateVolumeFill(!nowMuted); }); function updateVolumeFill(muted = btnMute.classList.contains('muted')) { volumeFill.style.width = currentVolume + '%'; volumePct.textContent = currentVolume + ' %'; volumeFill.classList.toggle('muted', muted); } function updateMuteBtn(muted) { btnMute.textContent = muted ? '🔇' : '🔊'; btnMute.classList.toggle('muted', muted); } // ── KillList ────────────────────────────────────────────────────────────────── $('btn-kill').addEventListener('click', () => { send({ cmd: 'killist_add' }); showToast('🚫 Track zur Skip-Liste hinzugefügt'); }); $('btn-show-killist').addEventListener('click', async () => { const list = await fetch('/api/killist').then(r => r.json()).catch(() => []); killistItems.innerHTML = ''; (list || []).forEach(title => { const li = document.createElement('li'); li.innerHTML = `${escHtml(title)}`; const btn = document.createElement('button'); btn.textContent = '✕'; btn.onclick = () => { send({ cmd: 'killist_remove', title }); li.remove(); }; li.appendChild(btn); killistItems.appendChild(li); }); killistPanel.classList.remove('hidden'); }); $('btn-close-killist').addEventListener('click', () => killistPanel.classList.add('hidden')); // ── Visualisation (Canvas) ──────────────────────────────────────────────────── function applyViz(bars) { if (!bars || bars.length === 0) return; lastBars = new Float32Array(bars); lastVizAt = performance.now(); } function renderFrame(ts = 0) { rafId = requestAnimationFrame(renderFrame); // 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 !== 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; 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 = Math.max(1, (w - gap * (n - 1)) / n); for (let i = 0; i < n; i++) { 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 = Math.round(i * (barW + gap)); const barH = val * h; 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 if (peaks[i] > 0.02) { 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 ─────────────────────────────────────────────────────────────────── function fmtTime(secs) { const m = Math.floor(secs / 60); const s = String(Math.floor(secs % 60)).padStart(2, '0'); return `${m}:${s}`; } function parseTime(str) { const [m, s] = (str || '0:00').split(':').map(Number); return m * 60 + (s || 0); } function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } let toastTimer; function showToast(msg) { let el = $('toast'); if (!el) { el = document.createElement('div'); el.id = 'toast'; el.style.cssText = [ 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)', 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px', 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s', 'pointer-events:none', ].join(';'); document.body.appendChild(el); } el.textContent = msg; el.style.opacity = '1'; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500); } // ── Boot ────────────────────────────────────────────────────────────────────── connect(); renderFrame();