|
|
|
@@ -1,9 +1,10 @@ |
|
|
|
'use strict'; |
|
|
|
|
|
|
|
// ── Compatibility: fetch polyfill via XMLHttpRequest ───────────────────────── |
|
|
|
// ── 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: .json() and { method, headers, body }. |
|
|
|
// 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 || {}; |
|
|
|
@@ -25,73 +26,75 @@ function apiFetch(url, opts) { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
// String.prototype.padStart not available in iOS < 10. |
|
|
|
function pad2(n) { return n < 10 ? '0' + n : '' + n; } |
|
|
|
|
|
|
|
// ── 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 playlistOverlay = $('playlist-overlay'); |
|
|
|
const playlistList = $('playlist-list'); |
|
|
|
const canvas = $('viz'); |
|
|
|
const ctx2d = canvas.getContext('2d'); |
|
|
|
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 ───────────────────────────────────────────────────────────────────── |
|
|
|
let currentVolume = 50; |
|
|
|
let currentPlaylistPos = 0; // 1-based, updated from status |
|
|
|
let currentRating = -1; // -1 = not yet loaded |
|
|
|
let lastRatedTitle = ''; // track we last fetched rating for |
|
|
|
let ws = null; |
|
|
|
let reconnectTimer = null; |
|
|
|
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 |
|
|
|
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; |
|
|
|
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 |
|
|
|
const VIZ_MODES = ['viz', 'actual', 'remaining']; |
|
|
|
let vizMode = 'viz'; |
|
|
|
let currentPosition = 0; |
|
|
|
let currentLength = 0; |
|
|
|
var VIZ_MODES = ['viz', 'actual', 'remaining']; |
|
|
|
var vizMode = 'viz'; |
|
|
|
var currentPosition = 0; |
|
|
|
var currentLength = 0; |
|
|
|
|
|
|
|
// ── WebSocket ───────────────────────────────────────────────────────────────── |
|
|
|
function connect() { |
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; |
|
|
|
ws = new WebSocket(`${proto}://${location.host}/ws`); |
|
|
|
var proto = location.protocol === 'https:' ? 'wss' : 'ws'; |
|
|
|
ws = new WebSocket(proto + '://' + location.host + '/ws'); |
|
|
|
|
|
|
|
ws.addEventListener('open', () => { |
|
|
|
ws.addEventListener('open', function() { |
|
|
|
statusDot.className = 'ok'; |
|
|
|
stateLabel.textContent = 'Verbunden'; |
|
|
|
clearTimeout(reconnectTimer); |
|
|
|
}); |
|
|
|
|
|
|
|
ws.addEventListener('close', () => { |
|
|
|
ws.addEventListener('close', function() { |
|
|
|
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; } |
|
|
|
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); |
|
|
|
}); |
|
|
|
@@ -114,12 +117,12 @@ function applyStatus(st) { |
|
|
|
} |
|
|
|
|
|
|
|
statusDot.className = 'ok'; |
|
|
|
const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; |
|
|
|
stateLabel.textContent = stateMap[st.state] ?? st.state; |
|
|
|
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 |
|
|
|
// Fetch rating whenever the track changes. |
|
|
|
if (st.title && st.title !== lastRatedTitle) { |
|
|
|
lastRatedTitle = st.title; |
|
|
|
fetchRating(); |
|
|
|
@@ -130,7 +133,7 @@ function applyStatus(st) { |
|
|
|
} |
|
|
|
|
|
|
|
playlistPos.textContent = st.playlist_length |
|
|
|
? `${st.playlist_pos} / ${st.playlist_length}` : ''; |
|
|
|
? st.playlist_pos + ' / ' + st.playlist_length : ''; |
|
|
|
|
|
|
|
if (st.playlist_pos !== currentPlaylistPos) { |
|
|
|
currentPlaylistPos = st.playlist_pos; |
|
|
|
@@ -160,59 +163,59 @@ function applyStatus(st) { |
|
|
|
} |
|
|
|
|
|
|
|
// ── Controls ────────────────────────────────────────────────────────────────── |
|
|
|
btnPlay.addEventListener('click', () => { |
|
|
|
// Optimistic toggle — server will push the real state back immediately. |
|
|
|
const playing = btnPlay.textContent === '⏸'; |
|
|
|
btnPlay.addEventListener('click', function() { |
|
|
|
var 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' })); |
|
|
|
$('btn-stop').addEventListener('click', function() { send({ cmd: 'stop' }); }); |
|
|
|
$('btn-next').addEventListener('click', function() { send({ cmd: 'next' }); }); |
|
|
|
$('btn-prev').addEventListener('click', function() { send({ cmd: 'prev' }); }); |
|
|
|
|
|
|
|
document.querySelectorAll('.btn-seek').forEach(btn => { |
|
|
|
btn.addEventListener('click', () => |
|
|
|
send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) })); |
|
|
|
document.querySelectorAll('.btn-seek').forEach(function(btn) { |
|
|
|
btn.addEventListener('click', function() { |
|
|
|
send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
$('progress-bar').addEventListener('click', async e => { |
|
|
|
// Derive total from current + remaining (timeLength shows "-mm:ss"). |
|
|
|
const current = parseTime(timeCurrent.textContent); |
|
|
|
const remaining = parseTime(timeLength.textContent.replace('-', '')); |
|
|
|
const total = current + remaining; |
|
|
|
$('progress-bar').addEventListener('click', function(e) { |
|
|
|
var current = parseTime(timeCurrent.textContent); |
|
|
|
var remaining = parseTime(timeLength.textContent.replace('-', '')); |
|
|
|
var total = current + remaining; |
|
|
|
if (!total) return; |
|
|
|
const rect = e.currentTarget.getBoundingClientRect(); |
|
|
|
const target = Math.round((e.clientX - rect.left) / rect.width * total); |
|
|
|
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', () => { |
|
|
|
$('btn-vol-up').addEventListener('click', function() { |
|
|
|
currentVolume = Math.min(100, currentVolume + 5); |
|
|
|
send({ cmd: 'volume', level: currentVolume }); |
|
|
|
updateVolumeFill(); |
|
|
|
}); |
|
|
|
$('btn-vol-down').addEventListener('click', () => { |
|
|
|
$('btn-vol-down').addEventListener('click', function() { |
|
|
|
currentVolume = Math.max(0, currentVolume - 5); |
|
|
|
send({ cmd: 'volume', level: currentVolume }); |
|
|
|
updateVolumeFill(); |
|
|
|
}); |
|
|
|
$('volume-bar').addEventListener('click', e => { |
|
|
|
const rect = e.currentTarget.getBoundingClientRect(); |
|
|
|
$('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', () => { |
|
|
|
const nowMuted = btnMute.classList.contains('muted'); |
|
|
|
btnMute.addEventListener('click', function() { |
|
|
|
var 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 + ' %'; |
|
|
|
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) { |
|
|
|
@@ -221,76 +224,78 @@ function updateMuteBtn(muted) { |
|
|
|
} |
|
|
|
|
|
|
|
// ── Rating (stars) ──────────────────────────────────────────────────────────── |
|
|
|
const starEls = document.querySelectorAll('.star'); |
|
|
|
|
|
|
|
async function fetchRating() { |
|
|
|
try { |
|
|
|
const r = await apiFetch('/api/rating').then(r => r.json()); |
|
|
|
currentRating = r.stars ?? 0; |
|
|
|
renderStars(currentRating); |
|
|
|
} catch { |
|
|
|
renderStars(0); |
|
|
|
} |
|
|
|
var starEls = document.querySelectorAll('.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(s => { |
|
|
|
const v = parseInt(s.dataset.v, 10); |
|
|
|
const lit = v <= 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(s => { |
|
|
|
s.addEventListener('click', async () => { |
|
|
|
const v = parseInt(s.dataset.v, 10); |
|
|
|
// Tap the current rating again → remove it (set to 0) |
|
|
|
const newRating = v === currentRating ? 0 : v; |
|
|
|
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); |
|
|
|
try { |
|
|
|
await apiFetch('/api/rating', { |
|
|
|
method: 'POST', |
|
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
|
body: JSON.stringify({ stars: newRating }), |
|
|
|
}); |
|
|
|
} catch { |
|
|
|
// Revert on error |
|
|
|
renderStars(currentRating); |
|
|
|
} |
|
|
|
apiFetch('/api/rating', { |
|
|
|
method: 'POST', |
|
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
|
body: JSON.stringify({ stars: newRating }), |
|
|
|
}).catch(function() { renderStars(prevRating); }); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
// ── KillList ────────────────────────────────────────────────────────────────── |
|
|
|
$('btn-kill').addEventListener('click', () => { |
|
|
|
$('btn-kill').addEventListener('click', function() { |
|
|
|
send({ cmd: 'killist_add' }); |
|
|
|
showToast('🚫 Track zur Skip-Liste hinzugefügt'); |
|
|
|
}); |
|
|
|
|
|
|
|
$('btn-show-killist').addEventListener('click', async () => { |
|
|
|
const list = await apiFetch('/api/killist').then(r => r.json()).catch(() => []); |
|
|
|
$('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(title => { |
|
|
|
const li = document.createElement('li'); |
|
|
|
li.innerHTML = `<span>${escHtml(title)}</span>`; |
|
|
|
const btn = document.createElement('button'); |
|
|
|
list.forEach(function(title) { |
|
|
|
var li = document.createElement('li'); |
|
|
|
li.innerHTML = '<span>' + escHtml(title) + '</span>'; |
|
|
|
var btn = document.createElement('button'); |
|
|
|
btn.textContent = '✕'; |
|
|
|
btn.onclick = () => { |
|
|
|
send({ cmd: 'killist_remove', title }); |
|
|
|
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', () => |
|
|
|
killistPanel.classList.add('hidden')); |
|
|
|
$('btn-close-killist').addEventListener('click', function() { |
|
|
|
killistPanel.classList.add('hidden'); |
|
|
|
}); |
|
|
|
|
|
|
|
// ── Visualisation (Canvas) ──────────────────────────────────────────────────── |
|
|
|
canvas.addEventListener('click', () => { |
|
|
|
canvas.addEventListener('click', function() { |
|
|
|
vizMode = VIZ_MODES[(VIZ_MODES.indexOf(vizMode) + 1) % VIZ_MODES.length]; |
|
|
|
}); |
|
|
|
|
|
|
|
@@ -300,22 +305,23 @@ function applyViz(bars) { |
|
|
|
lastVizAt = performance.now(); |
|
|
|
} |
|
|
|
|
|
|
|
function renderFrame(ts = 0) { |
|
|
|
function renderFrame(ts) { |
|
|
|
ts = 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)) { |
|
|
|
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); |
|
|
|
} |
|
|
|
|
|
|
|
const w = cssW; |
|
|
|
const h = cssH; |
|
|
|
var w = cssW; |
|
|
|
var h = cssH; |
|
|
|
if (w === 0 || h === 0) return; |
|
|
|
|
|
|
|
// Background |
|
|
|
@@ -324,82 +330,73 @@ function renderFrame(ts = 0) { |
|
|
|
|
|
|
|
// ── Time display modes ────────────────────────────────────────────────────── |
|
|
|
if (vizMode === 'actual' || vizMode === 'remaining') { |
|
|
|
const isActual = vizMode === 'actual'; |
|
|
|
const secs = isActual ? currentPosition : Math.max(0, currentLength - currentPosition); |
|
|
|
const prefix = isActual ? '' : '-'; |
|
|
|
const timeStr = prefix + fmtTimeLong(secs); |
|
|
|
const label = isActual ? 'ELAPSED' : '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'; |
|
|
|
|
|
|
|
// Large time |
|
|
|
ctx2d.font = `bold ${Math.round(h * 0.52)}px monospace`; |
|
|
|
ctx2d.font = 'bold ' + Math.round(h * 0.52) + 'px monospace'; |
|
|
|
ctx2d.fillStyle = isActual ? '#e0e0e0' : '#e94560'; |
|
|
|
ctx2d.fillText(timeStr, w / 2, h * 0.48); |
|
|
|
|
|
|
|
// Small label below |
|
|
|
ctx2d.font = `${Math.round(h * 0.18)}px monospace`; |
|
|
|
ctx2d.font = Math.round(h * 0.18) + 'px monospace'; |
|
|
|
ctx2d.fillStyle = '#444'; |
|
|
|
ctx2d.fillText(label, w / 2, h * 0.82); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// ── Spectrum mode ─────────────────────────────────────────────────────────── |
|
|
|
// Check if real viz data is fresh (< 1.5 s old) and Winamp is playing. |
|
|
|
const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500; |
|
|
|
var hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500; |
|
|
|
|
|
|
|
// Classic fixed vertical gradient: green at bottom → yellow → red at top. |
|
|
|
// Defined in canvas coords so every bar shows the same colour at the same |
|
|
|
// height — no per-frame hue calculation, no flicker. |
|
|
|
const grad = ctx2d.createLinearGradient(0, h, 0, 0); |
|
|
|
var grad = ctx2d.createLinearGradient(0, h, 0, 0); |
|
|
|
if (hasSignal) { |
|
|
|
grad.addColorStop(0, '#00aa00'); |
|
|
|
grad.addColorStop(0.6, '#aaaa00'); |
|
|
|
grad.addColorStop(1, '#cc0000'); |
|
|
|
grad.addColorStop(0, '#00aa00'); |
|
|
|
grad.addColorStop(0.6, '#aaaa00'); |
|
|
|
grad.addColorStop(1, '#cc0000'); |
|
|
|
} else { |
|
|
|
// Idle: same palette but much dimmer |
|
|
|
grad.addColorStop(0, '#003300'); |
|
|
|
grad.addColorStop(0.6, '#333300'); |
|
|
|
grad.addColorStop(1, '#330000'); |
|
|
|
grad.addColorStop(0, '#003300'); |
|
|
|
grad.addColorStop(0.6, '#333300'); |
|
|
|
grad.addColorStop(1, '#330000'); |
|
|
|
} |
|
|
|
|
|
|
|
const n = NUM_BARS; |
|
|
|
const gap = 1; |
|
|
|
const barW = Math.max(1, (w - gap * (n - 1)) / n); |
|
|
|
var n = NUM_BARS; |
|
|
|
var gap = 1; |
|
|
|
var barW = Math.max(1, (w - gap * (n - 1)) / n); |
|
|
|
|
|
|
|
for (let i = 0; i < n; i++) { |
|
|
|
let val; |
|
|
|
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); // gentle decay |
|
|
|
else peaks[i] = Math.max(0, peaks[i] - 0.008); |
|
|
|
} else { |
|
|
|
// Idle: slow sine breathing |
|
|
|
const phase = (ts / 1800) + (i / n) * Math.PI * 2; |
|
|
|
const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; |
|
|
|
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); |
|
|
|
} |
|
|
|
|
|
|
|
const x = Math.round(i * (barW + gap)); |
|
|
|
const barH = val * h; |
|
|
|
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); |
|
|
|
} |
|
|
|
|
|
|
|
// Peak dot — 1 px taller slice of the same gradient, slightly brighter |
|
|
|
if (peaks[i] > 0.02) { |
|
|
|
const py = Math.round(h - peaks[i] * h) - 1; |
|
|
|
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); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// "No signal" label when disconnected or stopped |
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) { |
|
|
|
drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333'); |
|
|
|
} else if (!winampPlaying) { |
|
|
|
@@ -408,41 +405,41 @@ function renderFrame(ts = 0) { |
|
|
|
} |
|
|
|
|
|
|
|
function drawLabel(ctx, w, h, text, color) { |
|
|
|
ctx.font = `bold ${Math.round(h * 0.22)}px monospace`; |
|
|
|
ctx.textAlign = 'center'; |
|
|
|
ctx.font = 'bold ' + Math.round(h * 0.22) + 'px monospace'; |
|
|
|
ctx.textAlign = 'center'; |
|
|
|
ctx.textBaseline = 'middle'; |
|
|
|
ctx.fillStyle = color; |
|
|
|
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}`; |
|
|
|
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); |
|
|
|
const h = Math.floor(secs / 3600); |
|
|
|
const m = Math.floor((secs % 3600) / 60); |
|
|
|
const s = secs % 60; |
|
|
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; |
|
|
|
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) { |
|
|
|
const [m, s] = (str || '0:00').split(':').map(Number); |
|
|
|
return m * 60 + (s || 0); |
|
|
|
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, '>'); |
|
|
|
} |
|
|
|
|
|
|
|
let toastTimer; |
|
|
|
var toastTimer; |
|
|
|
function showToast(msg) { |
|
|
|
let el = $('toast'); |
|
|
|
var el = $('toast'); |
|
|
|
if (!el) { |
|
|
|
el = document.createElement('div'); |
|
|
|
el.id = 'toast'; |
|
|
|
@@ -457,72 +454,71 @@ function showToast(msg) { |
|
|
|
el.textContent = msg; |
|
|
|
el.style.opacity = '1'; |
|
|
|
clearTimeout(toastTimer); |
|
|
|
toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500); |
|
|
|
toastTimer = setTimeout(function() { el.style.opacity = '0'; }, 2500); |
|
|
|
} |
|
|
|
|
|
|
|
// ── Playlist ────────────────────────────────────────────────────────────────── |
|
|
|
$('btn-show-playlist').addEventListener('click', openPlaylist); |
|
|
|
$('btn-close-playlist').addEventListener('click', () => { |
|
|
|
$('btn-close-playlist').addEventListener('click', function() { |
|
|
|
playlistOverlay.classList.add('hidden'); |
|
|
|
}); |
|
|
|
|
|
|
|
async function openPlaylist() { |
|
|
|
function openPlaylist() { |
|
|
|
playlistOverlay.classList.remove('hidden'); |
|
|
|
playlistList.innerHTML = '<li id="playlist-loading">Lade…</li>'; |
|
|
|
|
|
|
|
let tracks; |
|
|
|
try { |
|
|
|
tracks = await apiFetch('/api/playlist').then(r => r.json()); |
|
|
|
} catch { |
|
|
|
playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>'; |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (!tracks || tracks.length === 0) { |
|
|
|
playlistList.innerHTML = '<li id="playlist-loading">Playlist leer</li>'; |
|
|
|
return; |
|
|
|
} |
|
|
|
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); |
|
|
|
}); |
|
|
|
|
|
|
|
const frag = document.createDocumentFragment(); |
|
|
|
tracks.forEach(t => { |
|
|
|
const li = document.createElement('li'); |
|
|
|
li.dataset.index = t.index - 1; // store 0-based for jump |
|
|
|
if (t.index === currentPlaylistPos) li.classList.add('current'); |
|
|
|
|
|
|
|
const idx = document.createElement('span'); |
|
|
|
idx.className = 'pl-idx'; |
|
|
|
idx.textContent = t.index; |
|
|
|
|
|
|
|
const title = document.createElement('span'); |
|
|
|
title.className = 'pl-title'; |
|
|
|
title.textContent = t.title || '–'; |
|
|
|
|
|
|
|
li.appendChild(idx); |
|
|
|
li.appendChild(title); |
|
|
|
li.addEventListener('click', () => { |
|
|
|
send({ cmd: 'jump', index: parseInt(li.dataset.index, 10) }); |
|
|
|
playlistOverlay.classList.add('hidden'); |
|
|
|
playlistList.innerHTML = ''; |
|
|
|
playlistList.appendChild(frag); |
|
|
|
scrollToCurrentTrack(); |
|
|
|
}) |
|
|
|
.catch(function() { |
|
|
|
playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>'; |
|
|
|
}); |
|
|
|
frag.appendChild(li); |
|
|
|
}); |
|
|
|
|
|
|
|
playlistList.innerHTML = ''; |
|
|
|
playlistList.appendChild(frag); |
|
|
|
scrollToCurrentTrack(); |
|
|
|
} |
|
|
|
|
|
|
|
function updatePlaylistHighlight() { |
|
|
|
if (playlistOverlay.classList.contains('hidden')) return; |
|
|
|
playlistList.querySelectorAll('li').forEach(li => { |
|
|
|
const isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1; |
|
|
|
playlistList.querySelectorAll('li').forEach(function(li) { |
|
|
|
var isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1; |
|
|
|
li.classList.toggle('current', isCurrent); |
|
|
|
}); |
|
|
|
scrollToCurrentTrack(); |
|
|
|
} |
|
|
|
|
|
|
|
function scrollToCurrentTrack() { |
|
|
|
const current = playlistList.querySelector('li.current'); |
|
|
|
if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' }); |
|
|
|
var current = playlistList.querySelector('li.current'); |
|
|
|
if (current) current.scrollIntoView(true); |
|
|
|
} |
|
|
|
|
|
|
|
// ── Boot ────────────────────────────────────────────────────────────────────── |
|
|
|
|