Web-based Winamp controller for CarPC � Go backend, mobile-first UI
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

595 行
21KB

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