diff --git a/web/static/app.js b/web/static/app.js index f71e889..b9d450d 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 = `${escHtml(title)}`; - const btn = document.createElement('button'); + list.forEach(function(title) { + var li = document.createElement('li'); + li.innerHTML = '' + escHtml(title) + ''; + 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, '>'); } -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 = '
  • Lade…
  • '; - let tracks; - try { - tracks = await apiFetch('/api/playlist').then(r => r.json()); - } catch { - playlistList.innerHTML = '
  • Fehler beim Laden
  • '; - return; - } - - if (!tracks || tracks.length === 0) { - playlistList.innerHTML = '
  • Playlist leer
  • '; - return; - } + apiFetch('/api/playlist') + .then(function(r) { return r.json(); }) + .then(function(tracks) { + if (!tracks || tracks.length === 0) { + playlistList.innerHTML = '
  • Playlist leer
  • '; + 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 = '
  • Fehler beim Laden
  • '; }); - 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 ──────────────────────────────────────────────────────────────────────