Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

441 řádky
16KB

  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 playlistOverlay = $('playlist-overlay');
  18. const playlistList = $('playlist-list');
  19. const canvas = $('viz');
  20. const ctx2d = canvas.getContext('2d');
  21. // ── State ─────────────────────────────────────────────────────────────────────
  22. let currentVolume = 50;
  23. let currentPlaylistPos = 0; // 1-based, updated from status
  24. let ws = null;
  25. let reconnectTimer = null;
  26. // Viz state
  27. const NUM_BARS = 64;
  28. const peaks = new Float32Array(NUM_BARS);
  29. let lastBars = new Float32Array(NUM_BARS);
  30. let rafId = null;
  31. let lastVizAt = 0; // timestamp of last received viz frame
  32. let winampPlaying = false;
  33. // Canvas display mode — cycles on click: viz → actual → remaining → viz
  34. const VIZ_MODES = ['viz', 'actual', 'remaining'];
  35. let vizMode = 'viz';
  36. let currentPosition = 0;
  37. let currentLength = 0;
  38. // ── WebSocket ─────────────────────────────────────────────────────────────────
  39. function connect() {
  40. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  41. ws = new WebSocket(`${proto}://${location.host}/ws`);
  42. ws.addEventListener('open', () => {
  43. statusDot.className = 'ok';
  44. stateLabel.textContent = 'Verbunden';
  45. clearTimeout(reconnectTimer);
  46. });
  47. ws.addEventListener('close', () => {
  48. statusDot.className = 'err';
  49. stateLabel.textContent = 'Verbindung unterbrochen…';
  50. ws = null;
  51. reconnectTimer = setTimeout(connect, 3000);
  52. });
  53. ws.addEventListener('error', () => ws.close());
  54. ws.addEventListener('message', e => {
  55. let msg;
  56. try { msg = JSON.parse(e.data); } catch { return; }
  57. if (msg.type === 'status') applyStatus(msg);
  58. if (msg.type === 'viz') applyViz(msg.bars);
  59. });
  60. }
  61. function send(obj) {
  62. if (ws && ws.readyState === WebSocket.OPEN) {
  63. ws.send(JSON.stringify(obj));
  64. }
  65. }
  66. // ── Status handler ────────────────────────────────────────────────────────────
  67. function applyStatus(st) {
  68. if (!st.running) {
  69. statusDot.className = 'err';
  70. stateLabel.textContent = 'Winamp nicht gestartet';
  71. trackTitle.textContent = '–';
  72. playlistPos.textContent = '';
  73. return;
  74. }
  75. statusDot.className = 'ok';
  76. const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' };
  77. stateLabel.textContent = stateMap[st.state] ?? st.state;
  78. trackTitle.textContent = st.title || '–';
  79. playlistPos.textContent = st.playlist_length
  80. ? `${st.playlist_pos} / ${st.playlist_length}` : '';
  81. if (st.playlist_pos !== currentPlaylistPos) {
  82. currentPlaylistPos = st.playlist_pos;
  83. updatePlaylistHighlight();
  84. }
  85. if (st.length > 0) {
  86. currentPosition = st.position;
  87. currentLength = st.length;
  88. progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%';
  89. timeCurrent.textContent = fmtTime(st.position);
  90. timeLength.textContent = '-' + fmtTime(st.length - st.position);
  91. } else {
  92. progressFill.style.width = '0%';
  93. timeCurrent.textContent = '0:00';
  94. timeLength.textContent = '-0:00';
  95. }
  96. btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶';
  97. winampPlaying = st.state === 'playing';
  98. if (typeof st.volume === 'number') {
  99. currentVolume = st.volume;
  100. updateVolumeFill(st.muted);
  101. updateMuteBtn(st.muted);
  102. }
  103. }
  104. // ── Controls ──────────────────────────────────────────────────────────────────
  105. btnPlay.addEventListener('click', () => {
  106. // Optimistic toggle — server will push the real state back immediately.
  107. const playing = btnPlay.textContent === '⏸';
  108. send({ cmd: playing ? 'pause' : 'play' });
  109. });
  110. $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' }));
  111. $('btn-next').addEventListener('click', () => send({ cmd: 'next' }));
  112. $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' }));
  113. document.querySelectorAll('.btn-seek').forEach(btn => {
  114. btn.addEventListener('click', () =>
  115. send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) }));
  116. });
  117. $('progress-bar').addEventListener('click', async e => {
  118. // Derive total from current + remaining (timeLength shows "-mm:ss").
  119. const current = parseTime(timeCurrent.textContent);
  120. const remaining = parseTime(timeLength.textContent.replace('-', ''));
  121. const total = current + remaining;
  122. if (!total) return;
  123. const rect = e.currentTarget.getBoundingClientRect();
  124. const target = Math.round((e.clientX - rect.left) / rect.width * total);
  125. send({ cmd: 'seek', delta: target - current });
  126. });
  127. // ── Volume ────────────────────────────────────────────────────────────────────
  128. $('btn-vol-up').addEventListener('click', () => {
  129. currentVolume = Math.min(100, currentVolume + 5);
  130. send({ cmd: 'volume', level: currentVolume });
  131. updateVolumeFill();
  132. });
  133. $('btn-vol-down').addEventListener('click', () => {
  134. currentVolume = Math.max(0, currentVolume - 5);
  135. send({ cmd: 'volume', level: currentVolume });
  136. updateVolumeFill();
  137. });
  138. $('volume-bar').addEventListener('click', e => {
  139. const rect = e.currentTarget.getBoundingClientRect();
  140. currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100);
  141. send({ cmd: 'volume', level: currentVolume });
  142. updateVolumeFill();
  143. });
  144. btnMute.addEventListener('click', () => {
  145. const nowMuted = btnMute.classList.contains('muted');
  146. send({ cmd: 'mute', muted: !nowMuted });
  147. updateMuteBtn(!nowMuted);
  148. updateVolumeFill(!nowMuted);
  149. });
  150. function updateVolumeFill(muted = btnMute.classList.contains('muted')) {
  151. volumeFill.style.width = currentVolume + '%';
  152. volumePct.textContent = currentVolume + ' %';
  153. volumeFill.classList.toggle('muted', muted);
  154. }
  155. function updateMuteBtn(muted) {
  156. btnMute.textContent = muted ? '🔇' : '🔊';
  157. btnMute.classList.toggle('muted', muted);
  158. }
  159. // ── KillList ──────────────────────────────────────────────────────────────────
  160. $('btn-kill').addEventListener('click', () => {
  161. send({ cmd: 'killist_add' });
  162. showToast('🚫 Track zur Skip-Liste hinzugefügt');
  163. });
  164. $('btn-show-killist').addEventListener('click', async () => {
  165. const list = await fetch('/api/killist').then(r => r.json()).catch(() => []);
  166. killistItems.innerHTML = '';
  167. (list || []).forEach(title => {
  168. const li = document.createElement('li');
  169. li.innerHTML = `<span>${escHtml(title)}</span>`;
  170. const btn = document.createElement('button');
  171. btn.textContent = '✕';
  172. btn.onclick = () => {
  173. send({ cmd: 'killist_remove', title });
  174. li.remove();
  175. };
  176. li.appendChild(btn);
  177. killistItems.appendChild(li);
  178. });
  179. killistPanel.classList.remove('hidden');
  180. });
  181. $('btn-close-killist').addEventListener('click', () =>
  182. killistPanel.classList.add('hidden'));
  183. // ── Visualisation (Canvas) ────────────────────────────────────────────────────
  184. canvas.addEventListener('click', () => {
  185. vizMode = VIZ_MODES[(VIZ_MODES.indexOf(vizMode) + 1) % VIZ_MODES.length];
  186. });
  187. function applyViz(bars) {
  188. if (!bars || bars.length === 0) return;
  189. lastBars = new Float32Array(bars);
  190. lastVizAt = performance.now();
  191. }
  192. function renderFrame(ts = 0) {
  193. rafId = requestAnimationFrame(renderFrame);
  194. // Resize canvas to CSS size (handles window resize / DPR).
  195. // Setting canvas.width resets the transform, so we re-apply scale.
  196. const dpr = window.devicePixelRatio || 1;
  197. const cssW = canvas.clientWidth;
  198. const cssH = canvas.clientHeight;
  199. if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) {
  200. canvas.width = Math.round(cssW * dpr);
  201. canvas.height = Math.round(cssH * dpr);
  202. ctx2d.scale(dpr, dpr);
  203. }
  204. const w = cssW;
  205. const h = cssH;
  206. if (w === 0 || h === 0) return;
  207. // Background
  208. ctx2d.fillStyle = '#000';
  209. ctx2d.fillRect(0, 0, w, h);
  210. // ── Time display modes ──────────────────────────────────────────────────────
  211. if (vizMode === 'actual' || vizMode === 'remaining') {
  212. const isActual = vizMode === 'actual';
  213. const secs = isActual ? currentPosition : Math.max(0, currentLength - currentPosition);
  214. const prefix = isActual ? '' : '-';
  215. const timeStr = prefix + fmtTimeLong(secs);
  216. const label = isActual ? 'ELAPSED' : 'REMAINING';
  217. ctx2d.textAlign = 'center';
  218. ctx2d.textBaseline = 'middle';
  219. // Large time
  220. ctx2d.font = `bold ${Math.round(h * 0.52)}px monospace`;
  221. ctx2d.fillStyle = isActual ? '#e0e0e0' : '#e94560';
  222. ctx2d.fillText(timeStr, w / 2, h * 0.48);
  223. // Small label below
  224. ctx2d.font = `${Math.round(h * 0.18)}px monospace`;
  225. ctx2d.fillStyle = '#444';
  226. ctx2d.fillText(label, w / 2, h * 0.82);
  227. return;
  228. }
  229. // ── Spectrum mode ───────────────────────────────────────────────────────────
  230. // Check if real viz data is fresh (< 1.5 s old) and Winamp is playing.
  231. const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500;
  232. const n = NUM_BARS;
  233. const gap = 1;
  234. const barW = Math.max(1, (w - gap * (n - 1)) / n);
  235. for (let i = 0; i < n; i++) {
  236. let val;
  237. if (hasSignal) {
  238. // Real spectrum data
  239. val = lastBars[i] || 0;
  240. if (val > peaks[i]) peaks[i] = val;
  241. else peaks[i] = Math.max(0, peaks[i] - 0.012);
  242. } else {
  243. // Idle animation: slow sine "breathing" across the bars.
  244. // Amplitude fades out when paused/stopped.
  245. const phase = (ts / 1800) + (i / n) * Math.PI * 2;
  246. const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; // 0..0.08
  247. val = Math.max(0, Math.sin(phase) * breath);
  248. peaks[i] = Math.max(0, peaks[i] - 0.02); // let peaks fall quickly
  249. }
  250. const x = Math.round(i * (barW + gap));
  251. const barH = val * h;
  252. if (barH > 0.5) {
  253. // Bar colour: green (120°) → yellow (60°) → red (0°)
  254. const hue = Math.round((1 - val) * 120);
  255. const lit = hasSignal ? 42 : 25; // dimmer in idle
  256. ctx2d.fillStyle = `hsl(${hue},100%,${lit}%)`;
  257. ctx2d.fillRect(x, h - barH, barW, barH);
  258. }
  259. // Peak indicator
  260. if (peaks[i] > 0.02) {
  261. const py = Math.round(h - peaks[i] * h) - 1;
  262. ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.2)';
  263. ctx2d.fillRect(x, py, barW, 2);
  264. }
  265. }
  266. // "No signal" label when disconnected or stopped
  267. if (!ws || ws.readyState !== WebSocket.OPEN) {
  268. drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333');
  269. } else if (!winampPlaying) {
  270. drawLabel(ctx2d, w, h, '▶ PLAY', '#1a3a1a');
  271. }
  272. }
  273. function drawLabel(ctx, w, h, text, color) {
  274. ctx.font = `bold ${Math.round(h * 0.22)}px monospace`;
  275. ctx.textAlign = 'center';
  276. ctx.textBaseline = 'middle';
  277. ctx.fillStyle = color;
  278. ctx.fillText(text, w / 2, h / 2);
  279. }
  280. // ── Helpers ───────────────────────────────────────────────────────────────────
  281. function fmtTime(secs) {
  282. const m = Math.floor(secs / 60);
  283. const s = String(Math.floor(secs % 60)).padStart(2, '0');
  284. return `${m}:${s}`;
  285. }
  286. // Like fmtTime but always zero-pads to hh:mm:ss for the canvas display.
  287. function fmtTimeLong(secs) {
  288. secs = Math.floor(secs);
  289. const h = Math.floor(secs / 3600);
  290. const m = Math.floor((secs % 3600) / 60);
  291. const s = secs % 60;
  292. return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
  293. }
  294. function parseTime(str) {
  295. const [m, s] = (str || '0:00').split(':').map(Number);
  296. return m * 60 + (s || 0);
  297. }
  298. function escHtml(s) {
  299. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  300. }
  301. let toastTimer;
  302. function showToast(msg) {
  303. let el = $('toast');
  304. if (!el) {
  305. el = document.createElement('div');
  306. el.id = 'toast';
  307. el.style.cssText = [
  308. 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
  309. 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px',
  310. 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s',
  311. 'pointer-events:none',
  312. ].join(';');
  313. document.body.appendChild(el);
  314. }
  315. el.textContent = msg;
  316. el.style.opacity = '1';
  317. clearTimeout(toastTimer);
  318. toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500);
  319. }
  320. // ── Playlist ──────────────────────────────────────────────────────────────────
  321. $('btn-show-playlist').addEventListener('click', openPlaylist);
  322. $('btn-close-playlist').addEventListener('click', () => {
  323. playlistOverlay.classList.add('hidden');
  324. });
  325. async function openPlaylist() {
  326. playlistOverlay.classList.remove('hidden');
  327. playlistList.innerHTML = '<li id="playlist-loading">Lade…</li>';
  328. let tracks;
  329. try {
  330. tracks = await fetch('/api/playlist').then(r => r.json());
  331. } catch {
  332. playlistList.innerHTML = '<li id="playlist-loading">Fehler beim Laden</li>';
  333. return;
  334. }
  335. if (!tracks || tracks.length === 0) {
  336. playlistList.innerHTML = '<li id="playlist-loading">Playlist leer</li>';
  337. return;
  338. }
  339. const frag = document.createDocumentFragment();
  340. tracks.forEach(t => {
  341. const li = document.createElement('li');
  342. li.dataset.index = t.index - 1; // store 0-based for jump
  343. if (t.index === currentPlaylistPos) li.classList.add('current');
  344. const idx = document.createElement('span');
  345. idx.className = 'pl-idx';
  346. idx.textContent = t.index;
  347. const title = document.createElement('span');
  348. title.className = 'pl-title';
  349. title.textContent = t.title || '–';
  350. li.appendChild(idx);
  351. li.appendChild(title);
  352. li.addEventListener('click', () => {
  353. send({ cmd: 'jump', index: parseInt(li.dataset.index, 10) });
  354. playlistOverlay.classList.add('hidden');
  355. });
  356. frag.appendChild(li);
  357. });
  358. playlistList.innerHTML = '';
  359. playlistList.appendChild(frag);
  360. scrollToCurrentTrack();
  361. }
  362. function updatePlaylistHighlight() {
  363. if (playlistOverlay.classList.contains('hidden')) return;
  364. playlistList.querySelectorAll('li').forEach(li => {
  365. const isCurrent = parseInt(li.dataset.index, 10) === currentPlaylistPos - 1;
  366. li.classList.toggle('current', isCurrent);
  367. });
  368. scrollToCurrentTrack();
  369. }
  370. function scrollToCurrentTrack() {
  371. const current = playlistList.querySelector('li.current');
  372. if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' });
  373. }
  374. // ── Boot ──────────────────────────────────────────────────────────────────────
  375. connect();
  376. renderFrame();