|
- '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+25B6 — works 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;
- 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 = '<span>' + escHtml(title) + '</span>';
- 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, '▶ 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, '<').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 = '<li id="playlist-loading">Lade…</li>';
-
- apiFetch('/api/playlist')
- .then(function(r) { return r.json(); })
- .then(function(tracks) {
- if (!tracks || tracks.length === 0) {
- playlistList.innerHTML = '<li id="playlist-loading">Playlist leer</li>';
- return;
- }
-
- var frag = document.createDocumentFragment();
- tracks.forEach(function(t) {
- var li = document.createElement('li');
- li.dataset.index = t.index - 1; // 0-based for jump
- if (t.index === currentPlaylistPos) li.classList.add('current');
-
- var idx = document.createElement('span');
- idx.className = 'pl-idx';
- idx.textContent = t.index;
-
- var title = document.createElement('span');
- title.className = 'pl-title';
- title.textContent = t.title || '–';
-
- li.appendChild(idx);
- li.appendChild(title);
- li.addEventListener('click', function() {
- send({ cmd: 'jump', index: parseInt(li.dataset.index, 10) });
- playlistOverlay.classList.add('hidden');
- });
- frag.appendChild(li);
- });
-
- playlistList.innerHTML = '';
- playlistList.appendChild(frag);
- scrollToCurrentTrack();
- })
- .catch(function() {
- playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>';
- });
- }
-
- function updatePlaylistHighlight() {
- if (playlistOverlay.classList.contains('hidden')) return;
- qsa('li', playlistList).forEach(function(li) {
- var isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1;
- li.classList.toggle('current', isCurrent);
- });
- scrollToCurrentTrack();
- }
-
- function scrollToCurrentTrack() {
- var current = playlistList.querySelector('li.current');
- if (current) current.scrollIntoView(true);
- }
-
- // ── Boot ──────────────────────────────────────────────────────────────────────
-
- // Replace emoji with legacy symbols on iOS 9 where the glyphs are missing.
- if (isLegacyIOS) {
- $('btn-prev').textContent = SYM.prev;
- $('btn-play').textContent = SYM.play;
- $('btn-stop').textContent = SYM.stop;
- $('btn-next').textContent = SYM.next;
- $('btn-mute').textContent = SYM.volOn;
- $('btn-show-playlist').textContent = SYM.playlist;
- $('btn-kill').textContent = SYM.skip + ' Überspringen';
- }
-
- connect();
- renderFrame();
|