'use strict'; // ── Compatibility helpers ───────────────────────────────────────────────────── // fetch() is available from Safari 10.1 / iOS 10.3 onwards. // For iOS 9 (iPad 2) we fall back to a minimal XHR wrapper that matches // the subset of the fetch API we actually use. function apiFetch(url, opts) { if (window.fetch) return fetch(url, opts); opts = opts || {}; return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open(opts.method || 'GET', url); var headers = opts.headers || {}; Object.keys(headers).forEach(function(k) { xhr.setRequestHeader(k, headers[k]); }); xhr.onload = function() { var text = xhr.responseText; resolve({ ok: xhr.status >= 200 && xhr.status < 300, json: function() { return JSON.parse(text); }, text: function() { return text; } }); }; xhr.onerror = function() { reject(new Error('XHR error')); }; xhr.send(opts.body || null); }); } // String.prototype.padStart not available in iOS < 10. function pad2(n) { return n < 10 ? '0' + n : '' + n; } // iOS version detection — used to pick emoji vs. ASCII symbols. // Media-control emoji (⏮ ⏸ ⏹ ⏭) and several other glyphs are absent on iOS 9. var isLegacyIOS = (function() { var m = navigator.userAgent.match(/OS (\d+)_/); return /iPad|iPhone/.test(navigator.userAgent) && m && parseInt(m[1], 10) <= 9; }()); // Symbol set: modern emoji for capable browsers, plain text for iOS ≤ 9. var SYM = isLegacyIOS ? { prev: '<<', play: '►', // U+25BA — text-presentation pointer (▶ U+25B6 fails as broken emoji on iOS 9) pause: '||', stop: '■', // U+25A0 — works on iOS 9 next: '>>', volOn: 'vol', volOff: 'mut', playlist:'PL', skip: '✕' // U+2715 — works on iOS 9 } : { prev: '⏮', play: '▶', pause: '⏸', stop: '⏹', next: '⏭', volOn: '🔊', volOff: '🔇', playlist:'📋', skip: '🚫' }; // NodeList.prototype.forEach not available in iOS < 10. // Wrap querySelectorAll results in a real Array before iterating. function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); } // ── DOM refs ────────────────────────────────────────────────────────────────── var $ = function(id) { return document.getElementById(id); }; var statusDot = $('winamp-status'); var stateLabel = $('state-label'); var trackTitle = $('track-title'); var playlistPos = $('playlist-pos'); var progressFill = $('progress-fill'); var timeCurrent = $('time-current'); var timeLength = $('time-length'); var volumeFill = $('volume-fill'); var volumePct = $('volume-pct'); var btnMute = $('btn-mute'); var btnPlay = $('btn-play'); var killistPanel = $('killist-panel'); var killistItems = $('killist-items'); var playlistOverlay = $('playlist-overlay'); var playlistList = $('playlist-list'); var canvas = $('viz'); var ctx2d = canvas.getContext('2d'); // ── State ───────────────────────────────────────────────────────────────────── var currentVolume = 50; var currentPlaylistPos = 0; // 1-based, updated from status var currentRating = -1; // -1 = not yet loaded var lastRatedTitle = ''; // track we last fetched rating for var ws = null; var reconnectTimer = null; // Viz state var NUM_BARS = 64; var peaks = new Float32Array(NUM_BARS); var lastBars = new Float32Array(NUM_BARS); var rafId = null; var lastVizAt = 0; // timestamp of last received viz frame var winampPlaying = false; // Canvas display mode — cycles on click: viz → actual → remaining → viz var VIZ_MODES = ['viz', 'actual', 'remaining']; var vizMode = 'viz'; var currentPosition = 0; var currentLength = 0; // ── WebSocket ───────────────────────────────────────────────────────────────── function connect() { var proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(proto + '://' + location.host + '/ws'); ws.addEventListener('open', function() { statusDot.className = 'ok'; stateLabel.textContent = 'Verbunden'; clearTimeout(reconnectTimer); }); ws.addEventListener('close', function() { statusDot.className = 'err'; stateLabel.textContent = 'Verbindung unterbrochen…'; ws = null; reconnectTimer = setTimeout(connect, 3000); }); ws.addEventListener('error', function() { ws.close(); }); ws.addEventListener('message', function(e) { var msg; try { msg = JSON.parse(e.data); } catch(_e) { 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'; var stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; stateLabel.textContent = stateMap[st.state] != null ? 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 : ''; if (st.playlist_pos !== currentPlaylistPos) { currentPlaylistPos = st.playlist_pos; updatePlaylistHighlight(); } if (st.length > 0) { currentPosition = st.position; currentLength = st.length; progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; timeCurrent.textContent = fmtTime(st.position); timeLength.textContent = '-' + fmtTime(st.length - st.position); } else { progressFill.style.width = '0%'; timeCurrent.textContent = '0:00'; timeLength.textContent = '-0:00'; } btnPlay.textContent = st.state === 'playing' ? SYM.pause : SYM.play; winampPlaying = st.state === 'playing'; if (typeof st.volume === 'number') { currentVolume = st.volume; updateVolumeFill(st.muted); updateMuteBtn(st.muted); } } // ── Controls ────────────────────────────────────────────────────────────────── btnPlay.addEventListener('click', function() { var playing = btnPlay.textContent === SYM.pause; send({ cmd: playing ? 'pause' : 'play' }); }); $('btn-stop').addEventListener('click', function() { send({ cmd: 'stop' }); }); $('btn-next').addEventListener('click', function() { send({ cmd: 'next' }); }); $('btn-prev').addEventListener('click', function() { send({ cmd: 'prev' }); }); qsa('.btn-seek').forEach(function(btn) { btn.addEventListener('click', function() { send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }); }); }); $('progress-bar').addEventListener('click', function(e) { var current = parseTime(timeCurrent.textContent); var remaining = parseTime(timeLength.textContent.replace('-', '')); var total = current + remaining; if (!total) return; var rect = e.currentTarget.getBoundingClientRect(); var target = Math.round((e.clientX - rect.left) / rect.width * total); send({ cmd: 'seek', delta: target - current }); }); // ── Volume ──────────────────────────────────────────────────────────────────── $('btn-vol-up').addEventListener('click', function() { currentVolume = Math.min(100, currentVolume + 5); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); $('btn-vol-down').addEventListener('click', function() { currentVolume = Math.max(0, currentVolume - 5); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); $('volume-bar').addEventListener('click', function(e) { var rect = e.currentTarget.getBoundingClientRect(); currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); send({ cmd: 'volume', level: currentVolume }); updateVolumeFill(); }); btnMute.addEventListener('click', function() { var nowMuted = btnMute.classList.contains('muted'); send({ cmd: 'mute', muted: !nowMuted }); updateMuteBtn(!nowMuted); updateVolumeFill(!nowMuted); }); function updateVolumeFill(muted) { if (muted === undefined) muted = btnMute.classList.contains('muted'); volumeFill.style.width = currentVolume + '%'; volumePct.textContent = currentVolume + ' %'; volumeFill.classList.toggle('muted', muted); } function updateMuteBtn(muted) { btnMute.textContent = muted ? SYM.volOff : SYM.volOn; if (isLegacyIOS) { btnMute.style.fontSize = '16px'; btnMute.style.fontWeight = 'bold'; } btnMute.classList.toggle('muted', muted); } // ── Rating (stars) ──────────────────────────────────────────────────────────── var starEls = qsa('.star'); function fetchRating() { apiFetch('/api/rating') .then(function(r) { return r.json(); }) .then(function(data) { currentRating = data.stars != null ? data.stars : 0; renderStars(currentRating); }) .catch(function() { renderStars(0); }); } function renderStars(n) { currentRating = n; starEls.forEach(function(s) { var v = parseInt(s.dataset.v, 10); var lit = v <= n; s.textContent = lit ? '★' : '☆'; s.classList.toggle('lit', lit); }); } starEls.forEach(function(s) { s.addEventListener('click', function() { var v = parseInt(s.dataset.v, 10); var newRating = v === currentRating ? 0 : v; var prevRating = currentRating; renderStars(newRating); apiFetch('/api/rating', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stars: newRating }), }).catch(function() { renderStars(prevRating); }); }); }); // ── KillList ────────────────────────────────────────────────────────────────── $('btn-kill').addEventListener('click', function() { send({ cmd: 'killist_add' }); showToast('🚫 Track zur Skip-Liste hinzugefügt'); }); $('btn-show-killist').addEventListener('click', function() { apiFetch('/api/killist') .then(function(r) { return r.json(); }) .then(function(list) { renderKillist(list || []); }) .catch(function() { renderKillist([]); }); }); function renderKillist(list) { killistItems.innerHTML = ''; list.forEach(function(title) { var li = document.createElement('li'); li.innerHTML = '' + escHtml(title) + ''; var btn = document.createElement('button'); btn.textContent = '✕'; btn.onclick = function() { send({ cmd: 'killist_remove', title: title }); li.remove(); }; li.appendChild(btn); killistItems.appendChild(li); }); killistPanel.classList.remove('hidden'); } $('btn-close-killist').addEventListener('click', function() { killistPanel.classList.add('hidden'); }); // ── Visualisation (Canvas) ──────────────────────────────────────────────────── canvas.addEventListener('click', function() { vizMode = VIZ_MODES[(VIZ_MODES.indexOf(vizMode) + 1) % VIZ_MODES.length]; }); function applyViz(bars) { if (!bars || bars.length === 0) return; lastBars = new Float32Array(bars); lastVizAt = performance.now(); } function renderFrame(ts) { ts = ts || 0; rafId = requestAnimationFrame(renderFrame); // Resize canvas to CSS size (handles window resize / DPR). var dpr = window.devicePixelRatio || 1; var cssW = canvas.clientWidth; var 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); } var w = cssW; var h = cssH; if (w === 0 || h === 0) return; // Background ctx2d.fillStyle = '#000'; ctx2d.fillRect(0, 0, w, h); // ── Time display modes ────────────────────────────────────────────────────── if (vizMode === 'actual' || vizMode === 'remaining') { var isActual = vizMode === 'actual'; var secs = isActual ? currentPosition : Math.max(0, currentLength - currentPosition); var prefix = isActual ? '' : '-'; var timeStr = prefix + fmtTimeLong(secs); var label = isActual ? 'ELAPSED' : 'REMAINING'; ctx2d.textAlign = 'center'; ctx2d.textBaseline = 'middle'; ctx2d.font = 'bold ' + Math.round(h * 0.52) + 'px monospace'; ctx2d.fillStyle = isActual ? '#e0e0e0' : '#e94560'; ctx2d.fillText(timeStr, w / 2, h * 0.48); ctx2d.font = Math.round(h * 0.18) + 'px monospace'; ctx2d.fillStyle = '#444'; ctx2d.fillText(label, w / 2, h * 0.82); return; } // ── Spectrum mode ─────────────────────────────────────────────────────────── var hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500; // Classic fixed vertical gradient: green at bottom → yellow → red at top. var grad = ctx2d.createLinearGradient(0, h, 0, 0); if (hasSignal) { grad.addColorStop(0, '#00aa00'); grad.addColorStop(0.6, '#aaaa00'); grad.addColorStop(1, '#cc0000'); } else { grad.addColorStop(0, '#003300'); grad.addColorStop(0.6, '#333300'); grad.addColorStop(1, '#330000'); } var n = NUM_BARS; var gap = 1; var barW = Math.max(1, (w - gap * (n - 1)) / n); for (var i = 0; i < n; i++) { var val; if (hasSignal) { val = lastBars[i] || 0; if (val > peaks[i]) peaks[i] = val; else peaks[i] = Math.max(0, peaks[i] - 0.008); } else { var phase = (ts / 1800) + (i / n) * Math.PI * 2; var breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; val = Math.max(0, Math.sin(phase) * breath); peaks[i] = Math.max(0, peaks[i] - 0.02); } var x = Math.round(i * (barW + gap)); var barH = val * h; if (barH > 0.5) { ctx2d.fillStyle = grad; ctx2d.fillRect(x, h - barH, barW, barH); } if (peaks[i] > 0.02) { var py = Math.round(h - peaks[i] * h) - 1; ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.15)'; ctx2d.fillRect(x, py, barW, 2); } } if (!ws || ws.readyState !== WebSocket.OPEN) { drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333'); } else if (!winampPlaying) { drawLabel(ctx2d, w, h, SYM.play + ' 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) { var m = Math.floor(secs / 60); var s = Math.floor(secs % 60); return m + ':' + pad2(s); } // Like fmtTime but always zero-pads to hh:mm:ss for the canvas display. function fmtTimeLong(secs) { secs = Math.floor(secs); var h = Math.floor(secs / 3600); var m = Math.floor((secs % 3600) / 60); var s = secs % 60; return pad2(h) + ':' + pad2(m) + ':' + pad2(s); } function parseTime(str) { var parts = (str || '0:00').split(':').map(Number); return parts[0] * 60 + (parts[1] || 0); } function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } var toastTimer; function showToast(msg) { var 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(function() { el.style.opacity = '0'; }, 2500); } // ── Playlist ────────────────────────────────────────────────────────────────── $('btn-show-playlist').addEventListener('click', openPlaylist); $('btn-close-playlist').addEventListener('click', function() { playlistOverlay.classList.add('hidden'); }); function openPlaylist() { playlistOverlay.classList.remove('hidden'); playlistList.innerHTML = '