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

220 строки
8.5KB

  1. 'use strict';
  2. const api = (path, opts = {}) =>
  3. fetch(path, opts).then(r => r.json()).catch(() => null);
  4. // ── State ─────────────────────────────────────────────────────────────────────
  5. let currentVolume = 50; // 0–100 (Windows system volume)
  6. let pollTimer = null;
  7. // ── DOM refs ──────────────────────────────────────────────────────────────────
  8. const $ = id => document.getElementById(id);
  9. const statusDot = $('winamp-status');
  10. const stateLabel = $('state-label');
  11. const trackTitle = $('track-title');
  12. const playlistPos = $('playlist-pos');
  13. const progressFill = $('progress-fill');
  14. const timeCurrent = $('time-current');
  15. const timeLength = $('time-length');
  16. const volumeFill = $('volume-fill');
  17. const volumePct = $('volume-pct');
  18. const btnMute = $('btn-mute');
  19. const btnPlay = $('btn-play');
  20. const killistPanel = $('killist-panel');
  21. const killistItems = $('killist-items');
  22. // ── Playback controls ─────────────────────────────────────────────────────────
  23. btnPlay.addEventListener('click', async () => {
  24. const st = await api('/api/status');
  25. if (!st) return;
  26. if (st.state === 'playing') {
  27. await api('/api/pause', { method: 'POST' });
  28. } else {
  29. await api('/api/play', { method: 'POST' });
  30. }
  31. poll();
  32. });
  33. $('btn-stop').addEventListener('click', async () => {
  34. await api('/api/stop', { method: 'POST' }); poll();
  35. });
  36. $('btn-next').addEventListener('click', async () => {
  37. await api('/api/next', { method: 'POST' }); poll();
  38. });
  39. $('btn-prev').addEventListener('click', async () => {
  40. await api('/api/prev', { method: 'POST' }); poll();
  41. });
  42. // ── Seek buttons ──────────────────────────────────────────────────────────────
  43. document.querySelectorAll('.btn-seek').forEach(btn => {
  44. btn.addEventListener('click', async () => {
  45. const delta = parseInt(btn.dataset.delta, 10);
  46. await api(`/api/seek?delta=${delta}`, { method: 'POST' });
  47. poll();
  48. });
  49. });
  50. // ── Progress bar click-to-seek ────────────────────────────────────────────────
  51. $('progress-bar').addEventListener('click', async e => {
  52. const rect = e.currentTarget.getBoundingClientRect();
  53. const frac = (e.clientX - rect.left) / rect.width;
  54. const st = await api('/api/status');
  55. if (!st || !st.length) return;
  56. const target = Math.round(frac * st.length);
  57. const delta = target - st.position;
  58. await api(`/api/seek?delta=${delta}`, { method: 'POST' });
  59. poll();
  60. });
  61. // ── Volume ────────────────────────────────────────────────────────────────────
  62. $('btn-vol-up').addEventListener('click', async () => {
  63. currentVolume = Math.min(100, currentVolume + 5);
  64. await api(`/api/volume?level=${currentVolume}`, { method: 'POST' });
  65. updateVolumeFill();
  66. });
  67. $('btn-vol-down').addEventListener('click', async () => {
  68. currentVolume = Math.max(0, currentVolume - 5);
  69. await api(`/api/volume?level=${currentVolume}`, { method: 'POST' });
  70. updateVolumeFill();
  71. });
  72. $('volume-bar').addEventListener('click', async e => {
  73. const rect = e.currentTarget.getBoundingClientRect();
  74. currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100);
  75. await api(`/api/volume?level=${currentVolume}`, { method: 'POST' });
  76. updateVolumeFill();
  77. });
  78. btnMute.addEventListener('click', async () => {
  79. const cur = await api('/api/mute');
  80. const newMuted = !(cur?.muted);
  81. await api(`/api/mute?muted=${newMuted}`, { method: 'POST' });
  82. updateMuteBtn(newMuted);
  83. });
  84. function updateVolumeFill(muted = false) {
  85. volumeFill.style.width = currentVolume + '%';
  86. volumePct.textContent = currentVolume + ' %';
  87. volumeFill.classList.toggle('muted', muted);
  88. }
  89. function updateMuteBtn(muted) {
  90. btnMute.textContent = muted ? '🔇' : '🔊';
  91. btnMute.classList.toggle('muted', muted);
  92. volumeFill.classList.toggle('muted', muted);
  93. }
  94. // ── KillList ──────────────────────────────────────────────────────────────────
  95. $('btn-kill').addEventListener('click', async () => {
  96. const res = await api('/api/killist', { method: 'POST' });
  97. if (res?.added) {
  98. showToast(`🚫 ${res.added}`);
  99. }
  100. });
  101. $('btn-show-killist').addEventListener('click', async () => {
  102. await refreshKillist();
  103. killistPanel.classList.remove('hidden');
  104. });
  105. $('btn-close-killist').addEventListener('click', () => {
  106. killistPanel.classList.add('hidden');
  107. });
  108. async function refreshKillist() {
  109. const list = await api('/api/killist');
  110. if (!list) return;
  111. killistItems.innerHTML = '';
  112. list.forEach(title => {
  113. const li = document.createElement('li');
  114. li.innerHTML = `<span>${escHtml(title)}</span>`;
  115. const btn = document.createElement('button');
  116. btn.textContent = '✕';
  117. btn.onclick = async () => {
  118. await api(`/api/killist?title=${encodeURIComponent(title)}`, { method: 'DELETE' });
  119. await refreshKillist();
  120. };
  121. li.appendChild(btn);
  122. killistItems.appendChild(li);
  123. });
  124. }
  125. // ── Polling ───────────────────────────────────────────────────────────────────
  126. async function poll() {
  127. const st = await api('/api/status');
  128. if (!st) {
  129. statusDot.className = 'err';
  130. statusDot.textContent = '●';
  131. stateLabel.textContent = 'Keine Verbindung';
  132. trackTitle.textContent = '–';
  133. return;
  134. }
  135. if (!st.running) {
  136. statusDot.className = 'err';
  137. stateLabel.textContent = 'Winamp nicht gestartet';
  138. trackTitle.textContent = '–';
  139. return;
  140. }
  141. statusDot.className = 'ok';
  142. const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' };
  143. stateLabel.textContent = stateMap[st.state] ?? st.state;
  144. trackTitle.textContent = st.title || '–';
  145. playlistPos.textContent = st.playlist_length
  146. ? `${st.playlist_pos} / ${st.playlist_length}`
  147. : '';
  148. if (st.length > 0) {
  149. progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%';
  150. timeCurrent.textContent = fmtTime(st.position);
  151. timeLength.textContent = fmtTime(st.length);
  152. } else {
  153. progressFill.style.width = '0%';
  154. }
  155. // Reflect play/pause state on button
  156. btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶';
  157. // System volume (always present in status response)
  158. if (typeof st.volume === 'number') {
  159. currentVolume = st.volume;
  160. updateVolumeFill(st.muted);
  161. updateMuteBtn(st.muted);
  162. }
  163. }
  164. function startPolling(intervalMs = 2000) {
  165. if (pollTimer) clearInterval(pollTimer);
  166. poll();
  167. pollTimer = setInterval(poll, intervalMs);
  168. }
  169. // ── Helpers ───────────────────────────────────────────────────────────────────
  170. function fmtTime(secs) {
  171. const m = Math.floor(secs / 60);
  172. const s = String(Math.floor(secs % 60)).padStart(2, '0');
  173. return `${m}:${s}`;
  174. }
  175. function escHtml(str) {
  176. return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  177. }
  178. let toastTimer;
  179. function showToast(msg) {
  180. let el = document.getElementById('toast');
  181. if (!el) {
  182. el = document.createElement('div');
  183. el.id = 'toast';
  184. el.style.cssText = `
  185. position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
  186. background:#333;color:#fff;padding:10px 20px;border-radius:8px;
  187. font-size:14px;z-index:999;opacity:0;transition:opacity .2s;
  188. `;
  189. document.body.appendChild(el);
  190. }
  191. el.textContent = msg;
  192. el.style.opacity = '1';
  193. clearTimeout(toastTimer);
  194. toastTimer = setTimeout(() => { el.style.opacity = '0'; }, 2500);
  195. }
  196. // ── Boot ──────────────────────────────────────────────────────────────────────
  197. startPolling(2000);