Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

322 wiersze
12KB

  1. 'use strict';
  2. // ── DOM refs ──────────────────────────────────────────────────────────────────
  3. const $ = id => document.getElementById(id);
  4. const statusDot = $('winamp-status');
  5. const stateLabel = $('state-label');
  6. const trackTitle = $('track-title');
  7. const playlistPos = $('playlist-pos');
  8. const progressFill = $('progress-fill');
  9. const timeCurrent = $('time-current');
  10. const timeLength = $('time-length');
  11. const volumeFill = $('volume-fill');
  12. const volumePct = $('volume-pct');
  13. const btnMute = $('btn-mute');
  14. const btnPlay = $('btn-play');
  15. const killistPanel = $('killist-panel');
  16. const killistItems = $('killist-items');
  17. const canvas = $('viz');
  18. const ctx2d = canvas.getContext('2d');
  19. // ── State ─────────────────────────────────────────────────────────────────────
  20. let currentVolume = 50;
  21. let ws = null;
  22. let reconnectTimer = null;
  23. // Viz state
  24. const NUM_BARS = 64;
  25. const peaks = new Float32Array(NUM_BARS);
  26. let lastBars = new Float32Array(NUM_BARS);
  27. let rafId = null;
  28. let lastVizAt = 0; // timestamp of last received viz frame
  29. let winampPlaying = false;
  30. // ── WebSocket ─────────────────────────────────────────────────────────────────
  31. function connect() {
  32. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  33. ws = new WebSocket(`${proto}://${location.host}/ws`);
  34. ws.addEventListener('open', () => {
  35. statusDot.className = 'ok';
  36. stateLabel.textContent = 'Verbunden';
  37. clearTimeout(reconnectTimer);
  38. });
  39. ws.addEventListener('close', () => {
  40. statusDot.className = 'err';
  41. stateLabel.textContent = 'Verbindung unterbrochen…';
  42. ws = null;
  43. reconnectTimer = setTimeout(connect, 3000);
  44. });
  45. ws.addEventListener('error', () => ws.close());
  46. ws.addEventListener('message', e => {
  47. let msg;
  48. try { msg = JSON.parse(e.data); } catch { return; }
  49. if (msg.type === 'status') applyStatus(msg);
  50. if (msg.type === 'viz') applyViz(msg.bars);
  51. });
  52. }
  53. function send(obj) {
  54. if (ws && ws.readyState === WebSocket.OPEN) {
  55. ws.send(JSON.stringify(obj));
  56. }
  57. }
  58. // ── Status handler ────────────────────────────────────────────────────────────
  59. function applyStatus(st) {
  60. if (!st.running) {
  61. statusDot.className = 'err';
  62. stateLabel.textContent = 'Winamp nicht gestartet';
  63. trackTitle.textContent = '–';
  64. playlistPos.textContent = '';
  65. return;
  66. }
  67. statusDot.className = 'ok';
  68. const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' };
  69. stateLabel.textContent = stateMap[st.state] ?? st.state;
  70. trackTitle.textContent = st.title || '–';
  71. playlistPos.textContent = st.playlist_length
  72. ? `${st.playlist_pos} / ${st.playlist_length}` : '';
  73. if (st.length > 0) {
  74. progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%';
  75. timeCurrent.textContent = fmtTime(st.position);
  76. timeLength.textContent = fmtTime(st.length);
  77. } else {
  78. progressFill.style.width = '0%';
  79. timeCurrent.textContent = '0:00';
  80. timeLength.textContent = '0:00';
  81. }
  82. btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶';
  83. winampPlaying = st.state === 'playing';
  84. if (typeof st.volume === 'number') {
  85. currentVolume = st.volume;
  86. updateVolumeFill(st.muted);
  87. updateMuteBtn(st.muted);
  88. }
  89. }
  90. // ── Controls ──────────────────────────────────────────────────────────────────
  91. btnPlay.addEventListener('click', () => {
  92. // Optimistic toggle — server will push the real state back immediately.
  93. const playing = btnPlay.textContent === '⏸';
  94. send({ cmd: playing ? 'pause' : 'play' });
  95. });
  96. $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' }));
  97. $('btn-next').addEventListener('click', () => send({ cmd: 'next' }));
  98. $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' }));
  99. document.querySelectorAll('.btn-seek').forEach(btn => {
  100. btn.addEventListener('click', () =>
  101. send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }));
  102. });
  103. $('progress-bar').addEventListener('click', async e => {
  104. // We need current length — read from last status (stored in DOM for now via timeLength).
  105. const total = parseTime(timeLength.textContent);
  106. if (!total) return;
  107. const rect = e.currentTarget.getBoundingClientRect();
  108. const target = Math.round((e.clientX - rect.left) / rect.width * total);
  109. const current = parseTime(timeCurrent.textContent);
  110. send({ cmd: 'seek', delta: target - current });
  111. });
  112. // ── Volume ────────────────────────────────────────────────────────────────────
  113. $('btn-vol-up').addEventListener('click', () => {
  114. currentVolume = Math.min(100, currentVolume + 5);
  115. send({ cmd: 'volume', level: currentVolume });
  116. updateVolumeFill();
  117. });
  118. $('btn-vol-down').addEventListener('click', () => {
  119. currentVolume = Math.max(0, currentVolume - 5);
  120. send({ cmd: 'volume', level: currentVolume });
  121. updateVolumeFill();
  122. });
  123. $('volume-bar').addEventListener('click', e => {
  124. const rect = e.currentTarget.getBoundingClientRect();
  125. currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100);
  126. send({ cmd: 'volume', level: currentVolume });
  127. updateVolumeFill();
  128. });
  129. btnMute.addEventListener('click', () => {
  130. const nowMuted = btnMute.classList.contains('muted');
  131. send({ cmd: 'mute', muted: !nowMuted });
  132. updateMuteBtn(!nowMuted);
  133. updateVolumeFill(!nowMuted);
  134. });
  135. function updateVolumeFill(muted = btnMute.classList.contains('muted')) {
  136. volumeFill.style.width = currentVolume + '%';
  137. volumePct.textContent = currentVolume + ' %';
  138. volumeFill.classList.toggle('muted', muted);
  139. }
  140. function updateMuteBtn(muted) {
  141. btnMute.textContent = muted ? '🔇' : '🔊';
  142. btnMute.classList.toggle('muted', muted);
  143. }
  144. // ── KillList ──────────────────────────────────────────────────────────────────
  145. $('btn-kill').addEventListener('click', () => {
  146. send({ cmd: 'killist_add' });
  147. showToast('🚫 Track zur Skip-Liste hinzugefügt');
  148. });
  149. $('btn-show-killist').addEventListener('click', async () => {
  150. const list = await fetch('/api/killist').then(r => r.json()).catch(() => []);
  151. killistItems.innerHTML = '';
  152. (list || []).forEach(title => {
  153. const li = document.createElement('li');
  154. li.innerHTML = `<span>${escHtml(title)}</span>`;
  155. const btn = document.createElement('button');
  156. btn.textContent = '✕';
  157. btn.onclick = () => {
  158. send({ cmd: 'killist_remove', title });
  159. li.remove();
  160. };
  161. li.appendChild(btn);
  162. killistItems.appendChild(li);
  163. });
  164. killistPanel.classList.remove('hidden');
  165. });
  166. $('btn-close-killist').addEventListener('click', () =>
  167. killistPanel.classList.add('hidden'));
  168. // ── Visualisation (Canvas) ────────────────────────────────────────────────────
  169. function applyViz(bars) {
  170. if (!bars || bars.length === 0) return;
  171. lastBars = new Float32Array(bars);
  172. lastVizAt = performance.now();
  173. }
  174. function renderFrame(ts = 0) {
  175. rafId = requestAnimationFrame(renderFrame);
  176. // Resize canvas to CSS size (handles window resize / DPR).
  177. // Setting canvas.width resets the transform, so we re-apply scale.
  178. const dpr = window.devicePixelRatio || 1;
  179. const cssW = canvas.clientWidth;
  180. const cssH = canvas.clientHeight;
  181. if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) {
  182. canvas.width = Math.round(cssW * dpr);
  183. canvas.height = Math.round(cssH * dpr);
  184. ctx2d.scale(dpr, dpr);
  185. }
  186. const w = cssW;
  187. const h = cssH;
  188. if (w === 0 || h === 0) return;
  189. // Check if real viz data is fresh (< 1.5 s old) and Winamp is playing.
  190. const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500;
  191. // Background
  192. ctx2d.fillStyle = '#000';
  193. ctx2d.fillRect(0, 0, w, h);
  194. const n = NUM_BARS;
  195. const gap = 1;
  196. const barW = Math.max(1, (w - gap * (n - 1)) / n);
  197. for (let i = 0; i < n; i++) {
  198. let val;
  199. if (hasSignal) {
  200. // Real spectrum data
  201. val = lastBars[i] || 0;
  202. if (val > peaks[i]) peaks[i] = val;
  203. else peaks[i] = Math.max(0, peaks[i] - 0.012);
  204. } else {
  205. // Idle animation: slow sine "breathing" across the bars.
  206. // Amplitude fades out when paused/stopped.
  207. const phase = (ts / 1800) + (i / n) * Math.PI * 2;
  208. const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; // 0..0.08
  209. val = Math.max(0, Math.sin(phase) * breath);
  210. peaks[i] = Math.max(0, peaks[i] - 0.02); // let peaks fall quickly
  211. }
  212. const x = Math.round(i * (barW + gap));
  213. const barH = val * h;
  214. if (barH > 0.5) {
  215. // Bar colour: green (120°) → yellow (60°) → red (0°)
  216. const hue = Math.round((1 - val) * 120);
  217. const lit = hasSignal ? 42 : 25; // dimmer in idle
  218. ctx2d.fillStyle = `hsl(${hue},100%,${lit}%)`;
  219. ctx2d.fillRect(x, h - barH, barW, barH);
  220. }
  221. // Peak indicator
  222. if (peaks[i] > 0.02) {
  223. const py = Math.round(h - peaks[i] * h) - 1;
  224. ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.2)';
  225. ctx2d.fillRect(x, py, barW, 2);
  226. }
  227. }
  228. // "No signal" label when disconnected or stopped
  229. if (!ws || ws.readyState !== WebSocket.OPEN) {
  230. drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333');
  231. } else if (!winampPlaying) {
  232. drawLabel(ctx2d, w, h, '▶ PLAY', '#1a3a1a');
  233. }
  234. }
  235. function drawLabel(ctx, w, h, text, color) {
  236. ctx.font = `bold ${Math.round(h * 0.22)}px monospace`;
  237. ctx.textAlign = 'center';
  238. ctx.textBaseline = 'middle';
  239. ctx.fillStyle = color;
  240. ctx.fillText(text, w / 2, h / 2);
  241. }
  242. // ── Helpers ───────────────────────────────────────────────────────────────────
  243. function fmtTime(secs) {
  244. const m = Math.floor(secs / 60);
  245. const s = String(Math.floor(secs % 60)).padStart(2, '0');
  246. return `${m}:${s}`;
  247. }
  248. function parseTime(str) {
  249. const [m, s] = (str || '0:00').split(':').map(Number);
  250. return m * 60 + (s || 0);
  251. }
  252. function escHtml(s) {
  253. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  254. }
  255. let toastTimer;
  256. function showToast(msg) {
  257. let el = $('toast');
  258. if (!el) {
  259. el = document.createElement('div');
  260. el.id = 'toast';
  261. el.style.cssText = [
  262. 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
  263. 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px',
  264. 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s',
  265. 'pointer-events:none',
  266. ].join(';');
  267. document.body.appendChild(el);
  268. }
  269. el.textContent = msg;
  270. el.style.opacity = '1';
  271. clearTimeout(toastTimer);
  272. toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500);
  273. }
  274. // ── Boot ──────────────────────────────────────────────────────────────────────
  275. connect();
  276. renderFrame();