Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

285 рядки
10KB

  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. // ── WebSocket ─────────────────────────────────────────────────────────────────
  29. function connect() {
  30. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  31. ws = new WebSocket(`${proto}://${location.host}/ws`);
  32. ws.addEventListener('open', () => {
  33. statusDot.className = 'ok';
  34. stateLabel.textContent = 'Verbunden';
  35. clearTimeout(reconnectTimer);
  36. });
  37. ws.addEventListener('close', () => {
  38. statusDot.className = 'err';
  39. stateLabel.textContent = 'Verbindung unterbrochen…';
  40. ws = null;
  41. reconnectTimer = setTimeout(connect, 3000);
  42. });
  43. ws.addEventListener('error', () => ws.close());
  44. ws.addEventListener('message', e => {
  45. let msg;
  46. try { msg = JSON.parse(e.data); } catch { return; }
  47. if (msg.type === 'status') applyStatus(msg);
  48. if (msg.type === 'viz') applyViz(msg.bars);
  49. });
  50. }
  51. function send(obj) {
  52. if (ws && ws.readyState === WebSocket.OPEN) {
  53. ws.send(JSON.stringify(obj));
  54. }
  55. }
  56. // ── Status handler ────────────────────────────────────────────────────────────
  57. function applyStatus(st) {
  58. if (!st.running) {
  59. statusDot.className = 'err';
  60. stateLabel.textContent = 'Winamp nicht gestartet';
  61. trackTitle.textContent = '–';
  62. playlistPos.textContent = '';
  63. return;
  64. }
  65. statusDot.className = 'ok';
  66. const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' };
  67. stateLabel.textContent = stateMap[st.state] ?? st.state;
  68. trackTitle.textContent = st.title || '–';
  69. playlistPos.textContent = st.playlist_length
  70. ? `${st.playlist_pos} / ${st.playlist_length}` : '';
  71. if (st.length > 0) {
  72. progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%';
  73. timeCurrent.textContent = fmtTime(st.position);
  74. timeLength.textContent = fmtTime(st.length);
  75. } else {
  76. progressFill.style.width = '0%';
  77. timeCurrent.textContent = '0:00';
  78. timeLength.textContent = '0:00';
  79. }
  80. btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶';
  81. if (typeof st.volume === 'number') {
  82. currentVolume = st.volume;
  83. updateVolumeFill(st.muted);
  84. updateMuteBtn(st.muted);
  85. }
  86. }
  87. // ── Controls ──────────────────────────────────────────────────────────────────
  88. btnPlay.addEventListener('click', () => {
  89. // Optimistic toggle — server will push the real state back immediately.
  90. const playing = btnPlay.textContent === '⏸';
  91. send({ cmd: playing ? 'pause' : 'play' });
  92. });
  93. $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' }));
  94. $('btn-next').addEventListener('click', () => send({ cmd: 'next' }));
  95. $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' }));
  96. document.querySelectorAll('.btn-seek').forEach(btn => {
  97. btn.addEventListener('click', () =>
  98. send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }));
  99. });
  100. $('progress-bar').addEventListener('click', async e => {
  101. // We need current length — read from last status (stored in DOM for now via timeLength).
  102. const total = parseTime(timeLength.textContent);
  103. if (!total) return;
  104. const rect = e.currentTarget.getBoundingClientRect();
  105. const target = Math.round((e.clientX - rect.left) / rect.width * total);
  106. const current = parseTime(timeCurrent.textContent);
  107. send({ cmd: 'seek', delta: target - current });
  108. });
  109. // ── Volume ────────────────────────────────────────────────────────────────────
  110. $('btn-vol-up').addEventListener('click', () => {
  111. currentVolume = Math.min(100, currentVolume + 5);
  112. send({ cmd: 'volume', level: currentVolume });
  113. updateVolumeFill();
  114. });
  115. $('btn-vol-down').addEventListener('click', () => {
  116. currentVolume = Math.max(0, currentVolume - 5);
  117. send({ cmd: 'volume', level: currentVolume });
  118. updateVolumeFill();
  119. });
  120. $('volume-bar').addEventListener('click', e => {
  121. const rect = e.currentTarget.getBoundingClientRect();
  122. currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100);
  123. send({ cmd: 'volume', level: currentVolume });
  124. updateVolumeFill();
  125. });
  126. btnMute.addEventListener('click', () => {
  127. const nowMuted = btnMute.classList.contains('muted');
  128. send({ cmd: 'mute', muted: !nowMuted });
  129. updateMuteBtn(!nowMuted);
  130. updateVolumeFill(!nowMuted);
  131. });
  132. function updateVolumeFill(muted = btnMute.classList.contains('muted')) {
  133. volumeFill.style.width = currentVolume + '%';
  134. volumePct.textContent = currentVolume + ' %';
  135. volumeFill.classList.toggle('muted', muted);
  136. }
  137. function updateMuteBtn(muted) {
  138. btnMute.textContent = muted ? '🔇' : '🔊';
  139. btnMute.classList.toggle('muted', muted);
  140. }
  141. // ── KillList ──────────────────────────────────────────────────────────────────
  142. $('btn-kill').addEventListener('click', () => {
  143. send({ cmd: 'killist_add' });
  144. showToast('🚫 Track zur Skip-Liste hinzugefügt');
  145. });
  146. $('btn-show-killist').addEventListener('click', async () => {
  147. const list = await fetch('/api/killist').then(r => r.json()).catch(() => []);
  148. killistItems.innerHTML = '';
  149. (list || []).forEach(title => {
  150. const li = document.createElement('li');
  151. li.innerHTML = `<span>${escHtml(title)}</span>`;
  152. const btn = document.createElement('button');
  153. btn.textContent = '✕';
  154. btn.onclick = () => {
  155. send({ cmd: 'killist_remove', title });
  156. li.remove();
  157. };
  158. li.appendChild(btn);
  159. killistItems.appendChild(li);
  160. });
  161. killistPanel.classList.remove('hidden');
  162. });
  163. $('btn-close-killist').addEventListener('click', () =>
  164. killistPanel.classList.add('hidden'));
  165. // ── Visualisation (Canvas) ────────────────────────────────────────────────────
  166. function applyViz(bars) {
  167. if (!bars || bars.length === 0) return;
  168. lastBars = new Float32Array(bars);
  169. }
  170. function renderFrame() {
  171. rafId = requestAnimationFrame(renderFrame);
  172. // Resize canvas to CSS size (handles window resize / DPR)
  173. const dpr = window.devicePixelRatio || 1;
  174. const cssW = canvas.clientWidth;
  175. const cssH = canvas.clientHeight;
  176. if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
  177. canvas.width = cssW * dpr;
  178. canvas.height = cssH * dpr;
  179. ctx2d.scale(dpr, dpr);
  180. }
  181. const w = cssW;
  182. const h = cssH;
  183. const n = lastBars.length || NUM_BARS;
  184. // Background
  185. ctx2d.fillStyle = '#000';
  186. ctx2d.fillRect(0, 0, w, h);
  187. const gap = 1;
  188. const barW = (w - gap * (n - 1)) / n;
  189. for (let i = 0; i < n; i++) {
  190. const val = lastBars[i] || 0;
  191. // Peak: fast rise, slow fall (2% per frame)
  192. if (val > peaks[i]) peaks[i] = val;
  193. else peaks[i] = Math.max(0, peaks[i] - 0.012);
  194. const x = i * (barW + gap);
  195. const barH = val * h;
  196. // Bar colour: green (120°) → yellow (60°) → red (0°) based on amplitude
  197. const hue = Math.round((1 - val) * 120);
  198. ctx2d.fillStyle = `hsl(${hue},100%,42%)`;
  199. ctx2d.fillRect(x, h - barH, barW, barH);
  200. // Peak indicator — thin white line
  201. if (peaks[i] > 0.02) {
  202. const py = h - peaks[i] * h - 1;
  203. ctx2d.fillStyle = 'rgba(255,255,255,0.75)';
  204. ctx2d.fillRect(x, py, barW, 2);
  205. }
  206. }
  207. }
  208. // ── Helpers ───────────────────────────────────────────────────────────────────
  209. function fmtTime(secs) {
  210. const m = Math.floor(secs / 60);
  211. const s = String(Math.floor(secs % 60)).padStart(2, '0');
  212. return `${m}:${s}`;
  213. }
  214. function parseTime(str) {
  215. const [m, s] = (str || '0:00').split(':').map(Number);
  216. return m * 60 + (s || 0);
  217. }
  218. function escHtml(s) {
  219. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  220. }
  221. let toastTimer;
  222. function showToast(msg) {
  223. let el = $('toast');
  224. if (!el) {
  225. el = document.createElement('div');
  226. el.id = 'toast';
  227. el.style.cssText = [
  228. 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
  229. 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px',
  230. 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s',
  231. 'pointer-events:none',
  232. ].join(';');
  233. document.body.appendChild(el);
  234. }
  235. el.textContent = msg;
  236. el.style.opacity = '1';
  237. clearTimeout(toastTimer);
  238. toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500);
  239. }
  240. // ── Boot ──────────────────────────────────────────────────────────────────────
  241. connect();
  242. renderFrame();