Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

531 Zeilen
19KB

  1. 'use strict';
  2. // ── Compatibility: fetch polyfill via XMLHttpRequest ─────────────────────────
  3. // fetch() is available from Safari 10.1 / iOS 10.3 onwards.
  4. // For iOS 9 (iPad 2) we fall back to a minimal XHR wrapper that matches the
  5. // subset of the fetch API we actually use: .json() and { method, headers, body }.
  6. function apiFetch(url, opts) {
  7. if (window.fetch) return fetch(url, opts);
  8. opts = opts || {};
  9. return new Promise(function(resolve, reject) {
  10. var xhr = new XMLHttpRequest();
  11. xhr.open(opts.method || 'GET', url);
  12. var headers = opts.headers || {};
  13. Object.keys(headers).forEach(function(k) { xhr.setRequestHeader(k, headers[k]); });
  14. xhr.onload = function() {
  15. var text = xhr.responseText;
  16. resolve({
  17. ok: xhr.status >= 200 && xhr.status < 300,
  18. json: function() { return JSON.parse(text); },
  19. text: function() { return text; }
  20. });
  21. };
  22. xhr.onerror = function() { reject(new Error('XHR error')); };
  23. xhr.send(opts.body || null);
  24. });
  25. }
  26. // ── DOM refs ──────────────────────────────────────────────────────────────────
  27. const $ = id => document.getElementById(id);
  28. const statusDot = $('winamp-status');
  29. const stateLabel = $('state-label');
  30. const trackTitle = $('track-title');
  31. const playlistPos = $('playlist-pos');
  32. const progressFill = $('progress-fill');
  33. const timeCurrent = $('time-current');
  34. const timeLength = $('time-length');
  35. const volumeFill = $('volume-fill');
  36. const volumePct = $('volume-pct');
  37. const btnMute = $('btn-mute');
  38. const btnPlay = $('btn-play');
  39. const killistPanel = $('killist-panel');
  40. const killistItems = $('killist-items');
  41. const playlistOverlay = $('playlist-overlay');
  42. const playlistList = $('playlist-list');
  43. const canvas = $('viz');
  44. const ctx2d = canvas.getContext('2d');
  45. // ── State ─────────────────────────────────────────────────────────────────────
  46. let currentVolume = 50;
  47. let currentPlaylistPos = 0; // 1-based, updated from status
  48. let currentRating = -1; // -1 = not yet loaded
  49. let lastRatedTitle = ''; // track we last fetched rating for
  50. let ws = null;
  51. let reconnectTimer = null;
  52. // Viz state
  53. const NUM_BARS = 64;
  54. const peaks = new Float32Array(NUM_BARS);
  55. let lastBars = new Float32Array(NUM_BARS);
  56. let rafId = null;
  57. let lastVizAt = 0; // timestamp of last received viz frame
  58. let winampPlaying = false;
  59. // Canvas display mode — cycles on click: viz → actual → remaining → viz
  60. const VIZ_MODES = ['viz', 'actual', 'remaining'];
  61. let vizMode = 'viz';
  62. let currentPosition = 0;
  63. let currentLength = 0;
  64. // ── WebSocket ─────────────────────────────────────────────────────────────────
  65. function connect() {
  66. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  67. ws = new WebSocket(`${proto}://${location.host}/ws`);
  68. ws.addEventListener('open', () => {
  69. statusDot.className = 'ok';
  70. stateLabel.textContent = 'Verbunden';
  71. clearTimeout(reconnectTimer);
  72. });
  73. ws.addEventListener('close', () => {
  74. statusDot.className = 'err';
  75. stateLabel.textContent = 'Verbindung unterbrochen…';
  76. ws = null;
  77. reconnectTimer = setTimeout(connect, 3000);
  78. });
  79. ws.addEventListener('error', () => ws.close());
  80. ws.addEventListener('message', e => {
  81. let msg;
  82. try { msg = JSON.parse(e.data); } catch { return; }
  83. if (msg.type === 'status') applyStatus(msg);
  84. if (msg.type === 'viz') applyViz(msg.bars);
  85. });
  86. }
  87. function send(obj) {
  88. if (ws && ws.readyState === WebSocket.OPEN) {
  89. ws.send(JSON.stringify(obj));
  90. }
  91. }
  92. // ── Status handler ────────────────────────────────────────────────────────────
  93. function applyStatus(st) {
  94. if (!st.running) {
  95. statusDot.className = 'err';
  96. stateLabel.textContent = 'Winamp nicht gestartet';
  97. trackTitle.textContent = '–';
  98. playlistPos.textContent = '';
  99. return;
  100. }
  101. statusDot.className = 'ok';
  102. const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' };
  103. stateLabel.textContent = stateMap[st.state] ?? st.state;
  104. trackTitle.textContent = st.title || '–';
  105. // Fetch rating whenever the track changes
  106. if (st.title && st.title !== lastRatedTitle) {
  107. lastRatedTitle = st.title;
  108. fetchRating();
  109. }
  110. if (!st.title || !st.running) {
  111. lastRatedTitle = '';
  112. renderStars(0);
  113. }
  114. playlistPos.textContent = st.playlist_length
  115. ? `${st.playlist_pos} / ${st.playlist_length}` : '';
  116. if (st.playlist_pos !== currentPlaylistPos) {
  117. currentPlaylistPos = st.playlist_pos;
  118. updatePlaylistHighlight();
  119. }
  120. if (st.length > 0) {
  121. currentPosition = st.position;
  122. currentLength = st.length;
  123. progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%';
  124. timeCurrent.textContent = fmtTime(st.position);
  125. timeLength.textContent = '-' + fmtTime(st.length - st.position);
  126. } else {
  127. progressFill.style.width = '0%';
  128. timeCurrent.textContent = '0:00';
  129. timeLength.textContent = '-0:00';
  130. }
  131. btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶';
  132. winampPlaying = st.state === 'playing';
  133. if (typeof st.volume === 'number') {
  134. currentVolume = st.volume;
  135. updateVolumeFill(st.muted);
  136. updateMuteBtn(st.muted);
  137. }
  138. }
  139. // ── Controls ──────────────────────────────────────────────────────────────────
  140. btnPlay.addEventListener('click', () => {
  141. // Optimistic toggle — server will push the real state back immediately.
  142. const playing = btnPlay.textContent === '⏸';
  143. send({ cmd: playing ? 'pause' : 'play' });
  144. });
  145. $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' }));
  146. $('btn-next').addEventListener('click', () => send({ cmd: 'next' }));
  147. $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' }));
  148. document.querySelectorAll('.btn-seek').forEach(btn => {
  149. btn.addEventListener('click', () =>
  150. send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }));
  151. });
  152. $('progress-bar').addEventListener('click', async e => {
  153. // Derive total from current + remaining (timeLength shows "-mm:ss").
  154. const current = parseTime(timeCurrent.textContent);
  155. const remaining = parseTime(timeLength.textContent.replace('-', ''));
  156. const total = current + remaining;
  157. if (!total) return;
  158. const rect = e.currentTarget.getBoundingClientRect();
  159. const target = Math.round((e.clientX - rect.left) / rect.width * total);
  160. send({ cmd: 'seek', delta: target - current });
  161. });
  162. // ── Volume ────────────────────────────────────────────────────────────────────
  163. $('btn-vol-up').addEventListener('click', () => {
  164. currentVolume = Math.min(100, currentVolume + 5);
  165. send({ cmd: 'volume', level: currentVolume });
  166. updateVolumeFill();
  167. });
  168. $('btn-vol-down').addEventListener('click', () => {
  169. currentVolume = Math.max(0, currentVolume - 5);
  170. send({ cmd: 'volume', level: currentVolume });
  171. updateVolumeFill();
  172. });
  173. $('volume-bar').addEventListener('click', e => {
  174. const rect = e.currentTarget.getBoundingClientRect();
  175. currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100);
  176. send({ cmd: 'volume', level: currentVolume });
  177. updateVolumeFill();
  178. });
  179. btnMute.addEventListener('click', () => {
  180. const nowMuted = btnMute.classList.contains('muted');
  181. send({ cmd: 'mute', muted: !nowMuted });
  182. updateMuteBtn(!nowMuted);
  183. updateVolumeFill(!nowMuted);
  184. });
  185. function updateVolumeFill(muted = btnMute.classList.contains('muted')) {
  186. volumeFill.style.width = currentVolume + '%';
  187. volumePct.textContent = currentVolume + ' %';
  188. volumeFill.classList.toggle('muted', muted);
  189. }
  190. function updateMuteBtn(muted) {
  191. btnMute.textContent = muted ? '🔇' : '🔊';
  192. btnMute.classList.toggle('muted', muted);
  193. }
  194. // ── Rating (stars) ────────────────────────────────────────────────────────────
  195. const starEls = document.querySelectorAll('.star');
  196. async function fetchRating() {
  197. try {
  198. const r = await apiFetch('/api/rating').then(r => r.json());
  199. currentRating = r.stars ?? 0;
  200. renderStars(currentRating);
  201. } catch {
  202. renderStars(0);
  203. }
  204. }
  205. function renderStars(n) {
  206. currentRating = n;
  207. starEls.forEach(s => {
  208. const v = parseInt(s.dataset.v, 10);
  209. const lit = v <= n;
  210. s.textContent = lit ? '★' : '☆';
  211. s.classList.toggle('lit', lit);
  212. });
  213. }
  214. starEls.forEach(s => {
  215. s.addEventListener('click', async () => {
  216. const v = parseInt(s.dataset.v, 10);
  217. // Tap the current rating again → remove it (set to 0)
  218. const newRating = v === currentRating ? 0 : v;
  219. renderStars(newRating);
  220. try {
  221. await apiFetch('/api/rating', {
  222. method: 'POST',
  223. headers: { 'Content-Type': 'application/json' },
  224. body: JSON.stringify({ stars: newRating }),
  225. });
  226. } catch {
  227. // Revert on error
  228. renderStars(currentRating);
  229. }
  230. });
  231. });
  232. // ── KillList ──────────────────────────────────────────────────────────────────
  233. $('btn-kill').addEventListener('click', () => {
  234. send({ cmd: 'killist_add' });
  235. showToast('🚫 Track zur Skip-Liste hinzugefügt');
  236. });
  237. $('btn-show-killist').addEventListener('click', async () => {
  238. const list = await apiFetch('/api/killist').then(r => r.json()).catch(() => []);
  239. killistItems.innerHTML = '';
  240. (list || []).forEach(title => {
  241. const li = document.createElement('li');
  242. li.innerHTML = `<span>${escHtml(title)}</span>`;
  243. const btn = document.createElement('button');
  244. btn.textContent = '✕';
  245. btn.onclick = () => {
  246. send({ cmd: 'killist_remove', title });
  247. li.remove();
  248. };
  249. li.appendChild(btn);
  250. killistItems.appendChild(li);
  251. });
  252. killistPanel.classList.remove('hidden');
  253. });
  254. $('btn-close-killist').addEventListener('click', () =>
  255. killistPanel.classList.add('hidden'));
  256. // ── Visualisation (Canvas) ────────────────────────────────────────────────────
  257. canvas.addEventListener('click', () => {
  258. vizMode = VIZ_MODES[(VIZ_MODES.indexOf(vizMode) + 1) % VIZ_MODES.length];
  259. });
  260. function applyViz(bars) {
  261. if (!bars || bars.length === 0) return;
  262. lastBars = new Float32Array(bars);
  263. lastVizAt = performance.now();
  264. }
  265. function renderFrame(ts = 0) {
  266. rafId = requestAnimationFrame(renderFrame);
  267. // Resize canvas to CSS size (handles window resize / DPR).
  268. // Setting canvas.width resets the transform, so we re-apply scale.
  269. const dpr = window.devicePixelRatio || 1;
  270. const cssW = canvas.clientWidth;
  271. const cssH = canvas.clientHeight;
  272. if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) {
  273. canvas.width = Math.round(cssW * dpr);
  274. canvas.height = Math.round(cssH * dpr);
  275. ctx2d.scale(dpr, dpr);
  276. }
  277. const w = cssW;
  278. const h = cssH;
  279. if (w === 0 || h === 0) return;
  280. // Background
  281. ctx2d.fillStyle = '#000';
  282. ctx2d.fillRect(0, 0, w, h);
  283. // ── Time display modes ──────────────────────────────────────────────────────
  284. if (vizMode === 'actual' || vizMode === 'remaining') {
  285. const isActual = vizMode === 'actual';
  286. const secs = isActual ? currentPosition : Math.max(0, currentLength - currentPosition);
  287. const prefix = isActual ? '' : '-';
  288. const timeStr = prefix + fmtTimeLong(secs);
  289. const label = isActual ? 'ELAPSED' : 'REMAINING';
  290. ctx2d.textAlign = 'center';
  291. ctx2d.textBaseline = 'middle';
  292. // Large time
  293. ctx2d.font = `bold ${Math.round(h * 0.52)}px monospace`;
  294. ctx2d.fillStyle = isActual ? '#e0e0e0' : '#e94560';
  295. ctx2d.fillText(timeStr, w / 2, h * 0.48);
  296. // Small label below
  297. ctx2d.font = `${Math.round(h * 0.18)}px monospace`;
  298. ctx2d.fillStyle = '#444';
  299. ctx2d.fillText(label, w / 2, h * 0.82);
  300. return;
  301. }
  302. // ── Spectrum mode ───────────────────────────────────────────────────────────
  303. // Check if real viz data is fresh (< 1.5 s old) and Winamp is playing.
  304. const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500;
  305. // Classic fixed vertical gradient: green at bottom → yellow → red at top.
  306. // Defined in canvas coords so every bar shows the same colour at the same
  307. // height — no per-frame hue calculation, no flicker.
  308. const grad = ctx2d.createLinearGradient(0, h, 0, 0);
  309. if (hasSignal) {
  310. grad.addColorStop(0, '#00aa00');
  311. grad.addColorStop(0.6, '#aaaa00');
  312. grad.addColorStop(1, '#cc0000');
  313. } else {
  314. // Idle: same palette but much dimmer
  315. grad.addColorStop(0, '#003300');
  316. grad.addColorStop(0.6, '#333300');
  317. grad.addColorStop(1, '#330000');
  318. }
  319. const n = NUM_BARS;
  320. const gap = 1;
  321. const barW = Math.max(1, (w - gap * (n - 1)) / n);
  322. for (let i = 0; i < n; i++) {
  323. let val;
  324. if (hasSignal) {
  325. val = lastBars[i] || 0;
  326. if (val > peaks[i]) peaks[i] = val;
  327. else peaks[i] = Math.max(0, peaks[i] - 0.008); // gentle decay
  328. } else {
  329. // Idle: slow sine breathing
  330. const phase = (ts / 1800) + (i / n) * Math.PI * 2;
  331. const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08;
  332. val = Math.max(0, Math.sin(phase) * breath);
  333. peaks[i] = Math.max(0, peaks[i] - 0.02);
  334. }
  335. const x = Math.round(i * (barW + gap));
  336. const barH = val * h;
  337. if (barH > 0.5) {
  338. ctx2d.fillStyle = grad;
  339. ctx2d.fillRect(x, h - barH, barW, barH);
  340. }
  341. // Peak dot — 1 px taller slice of the same gradient, slightly brighter
  342. if (peaks[i] > 0.02) {
  343. const py = Math.round(h - peaks[i] * h) - 1;
  344. ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.15)';
  345. ctx2d.fillRect(x, py, barW, 2);
  346. }
  347. }
  348. // "No signal" label when disconnected or stopped
  349. if (!ws || ws.readyState !== WebSocket.OPEN) {
  350. drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333');
  351. } else if (!winampPlaying) {
  352. drawLabel(ctx2d, w, h, '▶ PLAY', '#1a3a1a');
  353. }
  354. }
  355. function drawLabel(ctx, w, h, text, color) {
  356. ctx.font = `bold ${Math.round(h * 0.22)}px monospace`;
  357. ctx.textAlign = 'center';
  358. ctx.textBaseline = 'middle';
  359. ctx.fillStyle = color;
  360. ctx.fillText(text, w / 2, h / 2);
  361. }
  362. // ── Helpers ───────────────────────────────────────────────────────────────────
  363. function fmtTime(secs) {
  364. const m = Math.floor(secs / 60);
  365. const s = String(Math.floor(secs % 60)).padStart(2, '0');
  366. return `${m}:${s}`;
  367. }
  368. // Like fmtTime but always zero-pads to hh:mm:ss for the canvas display.
  369. function fmtTimeLong(secs) {
  370. secs = Math.floor(secs);
  371. const h = Math.floor(secs / 3600);
  372. const m = Math.floor((secs % 3600) / 60);
  373. const s = secs % 60;
  374. return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
  375. }
  376. function parseTime(str) {
  377. const [m, s] = (str || '0:00').split(':').map(Number);
  378. return m * 60 + (s || 0);
  379. }
  380. function escHtml(s) {
  381. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  382. }
  383. let toastTimer;
  384. function showToast(msg) {
  385. let el = $('toast');
  386. if (!el) {
  387. el = document.createElement('div');
  388. el.id = 'toast';
  389. el.style.cssText = [
  390. 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
  391. 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px',
  392. 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s',
  393. 'pointer-events:none',
  394. ].join(';');
  395. document.body.appendChild(el);
  396. }
  397. el.textContent = msg;
  398. el.style.opacity = '1';
  399. clearTimeout(toastTimer);
  400. toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500);
  401. }
  402. // ── Playlist ──────────────────────────────────────────────────────────────────
  403. $('btn-show-playlist').addEventListener('click', openPlaylist);
  404. $('btn-close-playlist').addEventListener('click', () => {
  405. playlistOverlay.classList.add('hidden');
  406. });
  407. async function openPlaylist() {
  408. playlistOverlay.classList.remove('hidden');
  409. playlistList.innerHTML = '<li id="playlist-loading">Lade…</li>';
  410. let tracks;
  411. try {
  412. tracks = await apiFetch('/api/playlist').then(r => r.json());
  413. } catch {
  414. playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>';
  415. return;
  416. }
  417. if (!tracks || tracks.length === 0) {
  418. playlistList.innerHTML = '<li id="playlist-loading">Playlist leer</li>';
  419. return;
  420. }
  421. const frag = document.createDocumentFragment();
  422. tracks.forEach(t => {
  423. const li = document.createElement('li');
  424. li.dataset.index = t.index - 1; // store 0-based for jump
  425. if (t.index === currentPlaylistPos) li.classList.add('current');
  426. const idx = document.createElement('span');
  427. idx.className = 'pl-idx';
  428. idx.textContent = t.index;
  429. const title = document.createElement('span');
  430. title.className = 'pl-title';
  431. title.textContent = t.title || '–';
  432. li.appendChild(idx);
  433. li.appendChild(title);
  434. li.addEventListener('click', () => {
  435. send({ cmd: 'jump', index: parseInt(li.dataset.index, 10) });
  436. playlistOverlay.classList.add('hidden');
  437. });
  438. frag.appendChild(li);
  439. });
  440. playlistList.innerHTML = '';
  441. playlistList.appendChild(frag);
  442. scrollToCurrentTrack();
  443. }
  444. function updatePlaylistHighlight() {
  445. if (playlistOverlay.classList.contains('hidden')) return;
  446. playlistList.querySelectorAll('li').forEach(li => {
  447. const isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1;
  448. li.classList.toggle('current', isCurrent);
  449. });
  450. scrollToCurrentTrack();
  451. }
  452. function scrollToCurrentTrack() {
  453. const current = playlistList.querySelector('li.current');
  454. if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' });
  455. }
  456. // ── Boot ──────────────────────────────────────────────────────────────────────
  457. connect();
  458. renderFrame();