Web-based Winamp controller for CarPC � Go backend, mobile-first UI
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

506 line
18KB

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