Переглянути джерело

fix(ui): remove iOS 9 syntax errors — app was completely dead on iPad 2

async/await, catch{} without binding, and ?? are all syntax errors in
Safari 9 (iOS 9). A single syntax error prevents the entire script from
parsing, so connect() never ran — explaining 'nicht verbunden'.

Removed / replaced every incompatible construct:
- async/await  → .then()/.catch() Promise chains
- catch {}     → catch(_e) {} (optional catch binding is ES2019)
- ??           → explicit != null ternary (nullish coalescing is ES2020)
- padStart()   → pad2() helper (String.prototype.padStart is ES2017,
                 not available in iOS < 10)
- scrollIntoView({block,behavior}) → scrollIntoView(true) (options
  object not supported in iOS 9, boolean form works everywhere)

Template literals, arrow functions, const/let, forEach, classList,
dataset, Promise, WebSocket, Canvas, performance.now() and
requestAnimationFrame are all fine in iOS 9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
Jan Svabenik 1 місяць тому
джерело
коміт
c3d10037ea
1 змінених файлів з 214 додано та 218 видалено
  1. +214
    -218
      web/static/app.js

+ 214
- 218
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 = `<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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 ──────────────────────────────────────────────────────────────────────


Завантаження…
Відмінити
Зберегти