Web-based Winamp controller for CarPC � Go backend, mobile-first UI
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

527 satır
19KB

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