Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

2104 行
64KB

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>fm-rds-tx</title>
  7. <style>
  8. @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap');
  9. :root {
  10. --bg: #0a0a0c;
  11. --bg-2: #0f1015;
  12. --surface: #111116;
  13. --surface2: #18181e;
  14. --surface3: #1f2028;
  15. --border: #2a2a35;
  16. --border-strong: #3a3a49;
  17. --text: #d4d4dc;
  18. --text-dim: #8b8b99;
  19. --text-muted: #666674;
  20. --accent: #ff3b30;
  21. --accent-glow: #ff3b3044;
  22. --green: #30d158;
  23. --green-glow: #30d15844;
  24. --amber: #ff9f0a;
  25. --amber-glow: #ff9f0a44;
  26. --blue: #0a84ff;
  27. --blue-glow: #0a84ff33;
  28. --mono: 'JetBrains Mono', monospace;
  29. --display: 'Archivo Black', sans-serif;
  30. --radius: 8px;
  31. --shadow: 0 10px 30px rgba(0,0,0,.25);
  32. }
  33. * { box-sizing: border-box; margin: 0; padding: 0; }
  34. html { color-scheme: dark; }
  35. body {
  36. background:
  37. radial-gradient(circle at top right, rgba(10,132,255,.06), transparent 28%),
  38. radial-gradient(circle at top left, rgba(255,59,48,.06), transparent 30%),
  39. var(--bg);
  40. color: var(--text);
  41. font-family: var(--mono);
  42. font-size: 13px;
  43. line-height: 1.5;
  44. min-height: 100vh;
  45. overflow-x: hidden;
  46. }
  47. body::before {
  48. content: '';
  49. position: fixed; inset: 0;
  50. background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px);
  51. pointer-events: none;
  52. z-index: 1000;
  53. }
  54. button, input { font: inherit; }
  55. button { user-select: none; }
  56. .app {
  57. max-width: 1120px;
  58. margin: 0 auto;
  59. padding: 18px;
  60. }
  61. .header {
  62. display: flex;
  63. align-items: flex-start;
  64. justify-content: space-between;
  65. gap: 18px;
  66. padding: 8px 0 22px;
  67. border-bottom: 1px solid var(--border);
  68. margin-bottom: 18px;
  69. }
  70. .header-main {
  71. display: flex;
  72. flex-direction: column;
  73. gap: 8px;
  74. }
  75. .header h1 {
  76. font-family: var(--display);
  77. font-size: 24px;
  78. letter-spacing: 2px;
  79. text-transform: uppercase;
  80. color: var(--accent);
  81. text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow);
  82. }
  83. .header-sub {
  84. display: flex;
  85. flex-wrap: wrap;
  86. gap: 8px;
  87. }
  88. .badge {
  89. display: inline-flex;
  90. align-items: center;
  91. gap: 8px;
  92. min-height: 28px;
  93. padding: 0 10px;
  94. border: 1px solid var(--border);
  95. border-radius: 999px;
  96. background: rgba(255,255,255,0.02);
  97. color: var(--text-dim);
  98. font-size: 11px;
  99. text-transform: uppercase;
  100. letter-spacing: .8px;
  101. }
  102. .badge strong {
  103. color: var(--text);
  104. font-weight: 700;
  105. }
  106. .header-status {
  107. display: flex;
  108. align-items: center;
  109. gap: 10px;
  110. padding-top: 6px;
  111. }
  112. .led {
  113. width: 10px;
  114. height: 10px;
  115. border-radius: 50%;
  116. background: #333;
  117. box-shadow: none;
  118. transition: all .25s ease;
  119. flex-shrink: 0;
  120. }
  121. .led.on-green {
  122. background: var(--green);
  123. box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-glow);
  124. }
  125. .led.on-red {
  126. background: var(--accent);
  127. box-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent-glow);
  128. }
  129. .led.on-amber {
  130. background: var(--amber);
  131. box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow);
  132. }
  133. .led.on-blue {
  134. background: var(--blue);
  135. box-shadow: 0 0 8px var(--blue), 0 0 20px var(--blue-glow);
  136. }
  137. .status-text {
  138. font-size: 10px;
  139. color: var(--text-dim);
  140. text-transform: uppercase;
  141. letter-spacing: 1.2px;
  142. }
  143. .layout {
  144. display: grid;
  145. grid-template-columns: minmax(0, 1.35fr) minmax(310px, .75fr);
  146. gap: 14px;
  147. align-items: start;
  148. }
  149. .stack { display: flex; flex-direction: column; gap: 12px; }
  150. .card {
  151. background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
  152. border: 1px solid var(--border);
  153. border-radius: var(--radius);
  154. box-shadow: var(--shadow);
  155. }
  156. .hero {
  157. padding: 16px;
  158. position: relative;
  159. overflow: hidden;
  160. }
  161. .hero.tx-live::after {
  162. content: '';
  163. position: absolute;
  164. inset: -40%;
  165. background: radial-gradient(circle, rgba(48,209,88,.12), transparent 55%);
  166. animation: pulseGlow 2.8s ease-in-out infinite;
  167. pointer-events: none;
  168. }
  169. .hero.tx-busy::after {
  170. content: '';
  171. position: absolute;
  172. inset: -50%;
  173. background: conic-gradient(from 0deg, transparent, rgba(255,159,10,.12), transparent 45%);
  174. animation: spinWash 2s linear infinite;
  175. pointer-events: none;
  176. }
  177. @keyframes pulseGlow {
  178. 0%, 100% { transform: scale(.95); opacity: .45; }
  179. 50% { transform: scale(1.05); opacity: .8; }
  180. }
  181. @keyframes spinWash {
  182. from { transform: rotate(0deg); }
  183. to { transform: rotate(360deg); }
  184. }
  185. @keyframes blinkSoft {
  186. 0%, 100% { opacity: 1; }
  187. 50% { opacity: .55; }
  188. }
  189. .tx-bar {
  190. position: relative;
  191. z-index: 1;
  192. display: grid;
  193. grid-template-columns: minmax(180px, 250px) 1fr auto;
  194. gap: 14px;
  195. align-items: center;
  196. }
  197. .freq-display-wrap {
  198. display: flex;
  199. flex-direction: column;
  200. gap: 6px;
  201. }
  202. .freq-display-label {
  203. font-size: 10px;
  204. text-transform: uppercase;
  205. letter-spacing: 1.4px;
  206. color: var(--text-dim);
  207. }
  208. .freq-display {
  209. font-family: var(--display);
  210. font-size: 38px;
  211. color: var(--green);
  212. text-shadow: 0 0 15px var(--green-glow);
  213. letter-spacing: 1px;
  214. line-height: 1;
  215. }
  216. .freq-display .unit {
  217. font-family: var(--mono);
  218. font-size: 14px;
  219. color: var(--text-dim);
  220. margin-left: 5px;
  221. }
  222. .tx-actions {
  223. display: flex;
  224. flex-wrap: wrap;
  225. gap: 10px;
  226. }
  227. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn {
  228. min-height: 40px;
  229. padding: 0 18px;
  230. border-radius: var(--radius);
  231. border: 1px solid var(--border);
  232. background: var(--surface2);
  233. color: var(--text);
  234. cursor: pointer;
  235. font-size: 12px;
  236. font-weight: 700;
  237. text-transform: uppercase;
  238. letter-spacing: 1px;
  239. transition: all .16s ease;
  240. }
  241. .tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover {
  242. transform: translateY(-1px);
  243. border-color: var(--border-strong);
  244. }
  245. .tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled {
  246. opacity: .45;
  247. cursor: not-allowed;
  248. transform: none;
  249. }
  250. .tx-btn.start { border-color: var(--green); color: var(--green); }
  251. .tx-btn.start:hover:not(:disabled) { background: rgba(48,209,88,.1); }
  252. .tx-btn.stop { border-color: var(--accent); color: var(--accent); }
  253. .tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); }
  254. .ghost-btn { color: var(--text-dim); }
  255. .danger-btn {
  256. border-color: rgba(255,59,48,.45);
  257. color: var(--accent);
  258. background: rgba(255,59,48,.04);
  259. }
  260. .danger-btn:hover:not(:disabled) {
  261. background: rgba(255,59,48,.12);
  262. }
  263. .tx-state-wrap {
  264. display: flex;
  265. flex-direction: column;
  266. align-items: flex-end;
  267. gap: 6px;
  268. }
  269. .tx-state {
  270. font-size: 11px;
  271. text-transform: uppercase;
  272. letter-spacing: 2px;
  273. color: var(--text-dim);
  274. }
  275. .tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; }
  276. .tx-state.idle, .tx-state.stopped { color: var(--text-dim); }
  277. .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; }
  278. .tx-state.error { color: var(--accent); }
  279. .status-hint {
  280. font-size: 10px;
  281. color: var(--text-muted);
  282. text-align: right;
  283. }
  284. .quick-grid {
  285. position: relative;
  286. z-index: 1;
  287. display: grid;
  288. grid-template-columns: repeat(5, minmax(0, 1fr));
  289. gap: 10px;
  290. margin-top: 16px;
  291. }
  292. .quick-item {
  293. padding: 12px;
  294. border: 1px solid var(--border);
  295. border-radius: var(--radius);
  296. background: var(--bg-2);
  297. }
  298. .quick-item .label {
  299. font-size: 9px;
  300. text-transform: uppercase;
  301. letter-spacing: 1.4px;
  302. color: var(--text-dim);
  303. margin-bottom: 6px;
  304. }
  305. .quick-item .value {
  306. font-size: 18px;
  307. font-weight: 700;
  308. color: var(--text);
  309. }
  310. .quick-item .value.warn { color: var(--amber); }
  311. .quick-item .value.err { color: var(--accent); }
  312. .quick-item .value.good { color: var(--green); }
  313. .signal-grid {
  314. position: relative;
  315. z-index: 1;
  316. display: grid;
  317. grid-template-columns: repeat(3, minmax(0, 1fr));
  318. gap: 10px;
  319. margin-top: 12px;
  320. }
  321. .signal-card {
  322. padding: 12px;
  323. border: 1px solid var(--border);
  324. border-radius: var(--radius);
  325. background: var(--bg-2);
  326. }
  327. .signal-head {
  328. display: flex;
  329. align-items: center;
  330. justify-content: space-between;
  331. gap: 10px;
  332. margin-bottom: 8px;
  333. }
  334. .signal-title {
  335. font-size: 10px;
  336. color: var(--text-dim);
  337. text-transform: uppercase;
  338. letter-spacing: 1.2px;
  339. }
  340. .signal-value {
  341. font-size: 11px;
  342. color: var(--text);
  343. font-weight: 700;
  344. }
  345. .meter {
  346. width: 100%;
  347. height: 10px;
  348. border-radius: 999px;
  349. background: #171821;
  350. border: 1px solid var(--border);
  351. overflow: hidden;
  352. }
  353. .meter-fill {
  354. height: 100%;
  355. width: 0%;
  356. transition: width .25s ease, background-color .25s ease;
  357. background: linear-gradient(90deg, var(--green), #5cff90);
  358. }
  359. .meter-fill.warn {
  360. background: linear-gradient(90deg, var(--amber), #ffc45b);
  361. }
  362. .meter-fill.err {
  363. background: linear-gradient(90deg, var(--accent), #ff6b63);
  364. }
  365. .spark {
  366. width: 100%;
  367. height: 34px;
  368. margin-top: 10px;
  369. border-radius: 6px;
  370. background: rgba(255,255,255,0.01);
  371. border: 1px solid rgba(255,255,255,0.03);
  372. }
  373. .spark path.line {
  374. fill: none;
  375. stroke-width: 2;
  376. stroke-linecap: round;
  377. stroke-linejoin: round;
  378. }
  379. .spark path.area {
  380. opacity: .14;
  381. }
  382. .spark.good path.line { stroke: var(--green); }
  383. .spark.good path.area { fill: var(--green); }
  384. .spark.warn path.line { stroke: var(--amber); }
  385. .spark.warn path.area { fill: var(--amber); }
  386. .spark.err path.line { stroke: var(--accent); }
  387. .spark.err path.area { fill: var(--accent); }
  388. .panel {
  389. overflow: hidden;
  390. }
  391. .panel-head {
  392. display: flex;
  393. align-items: center;
  394. gap: 8px;
  395. padding: 12px 14px;
  396. border-bottom: 1px solid var(--border);
  397. background: var(--surface2);
  398. cursor: pointer;
  399. user-select: none;
  400. }
  401. .panel-head h2 {
  402. font-size: 11px;
  403. font-weight: 700;
  404. text-transform: uppercase;
  405. letter-spacing: 1.6px;
  406. color: var(--text-dim);
  407. }
  408. .panel-head .meta {
  409. margin-left: auto;
  410. margin-right: 8px;
  411. font-size: 10px;
  412. color: var(--text-muted);
  413. text-transform: uppercase;
  414. letter-spacing: 1px;
  415. }
  416. .panel-head .chevron {
  417. color: var(--text-dim);
  418. transition: transform .2s ease;
  419. font-size: 10px;
  420. }
  421. .panel-head.collapsed .chevron { transform: rotate(-90deg); }
  422. .panel-body { padding: 14px; }
  423. .panel-body.collapsed { display: none; }
  424. .section-note {
  425. font-size: 11px;
  426. color: var(--text-muted);
  427. margin-bottom: 12px;
  428. }
  429. .shortcuts-grid {
  430. display: grid;
  431. grid-template-columns: repeat(2, minmax(0, 1fr));
  432. gap: 8px 12px;
  433. }
  434. .shortcut-line {
  435. display: flex;
  436. align-items: center;
  437. justify-content: space-between;
  438. gap: 12px;
  439. padding: 7px 0;
  440. border-bottom: 1px solid #1a1a22;
  441. }
  442. .shortcut-line:last-child { border-bottom: none; }
  443. .shortcut-line .name { font-size: 11px; color: var(--text-dim); }
  444. .shortcut-line .keys {
  445. display: inline-flex;
  446. gap: 6px;
  447. flex-wrap: wrap;
  448. }
  449. .kbd {
  450. min-width: 28px;
  451. padding: 3px 7px;
  452. border: 1px solid var(--border);
  453. border-bottom-width: 2px;
  454. border-radius: 6px;
  455. background: var(--bg-2);
  456. font-size: 10px;
  457. color: var(--text);
  458. text-align: center;
  459. }
  460. .preset-row {
  461. display: flex;
  462. flex-wrap: wrap;
  463. gap: 8px;
  464. margin-bottom: 12px;
  465. }
  466. .preset-btn {
  467. min-height: 34px;
  468. padding: 0 12px;
  469. font-size: 11px;
  470. letter-spacing: .8px;
  471. color: var(--text-dim);
  472. }
  473. .preset-btn.active {
  474. border-color: var(--blue);
  475. color: var(--blue);
  476. background: rgba(10,132,255,.08);
  477. }
  478. .preset-btn.rds {
  479. text-transform: none;
  480. font-weight: 600;
  481. }
  482. .ctrl-row {
  483. display: flex;
  484. align-items: center;
  485. gap: 12px;
  486. padding: 10px 0;
  487. border-bottom: 1px solid #1a1a22;
  488. }
  489. .ctrl-row:last-child { border-bottom: none; }
  490. .ctrl-label-wrap {
  491. min-width: 130px;
  492. display: flex;
  493. flex-direction: column;
  494. gap: 2px;
  495. }
  496. .ctrl-label {
  497. font-size: 11px;
  498. color: var(--text-dim);
  499. text-transform: uppercase;
  500. letter-spacing: .8px;
  501. }
  502. .ctrl-sub {
  503. font-size: 10px;
  504. color: var(--text-muted);
  505. }
  506. .ctrl-input {
  507. flex: 1;
  508. display: flex;
  509. align-items: center;
  510. gap: 10px;
  511. }
  512. input[type="range"] {
  513. -webkit-appearance: none;
  514. appearance: none;
  515. flex: 1;
  516. height: 6px;
  517. background: linear-gradient(90deg, var(--border), var(--surface3));
  518. border-radius: 999px;
  519. outline: none;
  520. }
  521. input[type="range"]::-webkit-slider-thumb {
  522. -webkit-appearance: none;
  523. width: 16px;
  524. height: 16px;
  525. border-radius: 50%;
  526. background: var(--text);
  527. border: 2px solid var(--bg);
  528. cursor: pointer;
  529. transition: background .15s ease, transform .15s ease;
  530. }
  531. input[type="range"]::-webkit-slider-thumb:hover {
  532. background: var(--accent);
  533. transform: scale(1.06);
  534. }
  535. input[type="number"], input[type="text"] {
  536. background: var(--bg);
  537. border: 1px solid var(--border);
  538. border-radius: 6px;
  539. color: var(--text);
  540. padding: 8px 10px;
  541. outline: none;
  542. transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
  543. }
  544. input[type="number"] {
  545. width: 92px;
  546. text-align: right;
  547. }
  548. input[type="text"] { width: 100%; }
  549. input:focus {
  550. border-color: var(--accent);
  551. box-shadow: 0 0 0 3px rgba(255,59,48,.12);
  552. }
  553. input.input-dirty {
  554. border-color: var(--amber);
  555. box-shadow: 0 0 0 3px rgba(255,159,10,.08);
  556. }
  557. input.input-error {
  558. border-color: var(--accent);
  559. box-shadow: 0 0 0 3px rgba(255,59,48,.14);
  560. background: rgba(255,59,48,.04);
  561. }
  562. .val-display {
  563. min-width: 64px;
  564. text-align: right;
  565. font-size: 12px;
  566. font-weight: 700;
  567. color: var(--text);
  568. }
  569. .unit-label {
  570. font-size: 11px;
  571. color: var(--text-dim);
  572. min-width: 44px;
  573. }
  574. .field-error {
  575. display: none;
  576. margin-top: 8px;
  577. font-size: 11px;
  578. color: var(--accent);
  579. }
  580. .field-error.show { display: block; }
  581. .toggle-row {
  582. display: flex;
  583. align-items: center;
  584. justify-content: space-between;
  585. gap: 14px;
  586. padding: 12px 0;
  587. border-bottom: 1px solid #1a1a22;
  588. }
  589. .toggle-row:last-child { border-bottom: none; }
  590. .toggle-copy {
  591. display: flex;
  592. flex-direction: column;
  593. gap: 3px;
  594. }
  595. .toggle-copy .title {
  596. font-size: 12px;
  597. color: var(--text);
  598. font-weight: 700;
  599. }
  600. .toggle-copy .sub {
  601. font-size: 10px;
  602. color: var(--text-muted);
  603. }
  604. .toggle-ctl {
  605. display: flex;
  606. align-items: center;
  607. gap: 10px;
  608. }
  609. .toggle {
  610. position: relative;
  611. width: 42px;
  612. height: 24px;
  613. background: var(--border);
  614. border-radius: 999px;
  615. cursor: pointer;
  616. transition: all .2s ease;
  617. flex-shrink: 0;
  618. }
  619. .toggle::after {
  620. content: '';
  621. position: absolute;
  622. top: 3px;
  623. left: 3px;
  624. width: 18px;
  625. height: 18px;
  626. background: var(--text);
  627. border-radius: 50%;
  628. transition: transform .2s ease;
  629. }
  630. .toggle.on { background: var(--green); }
  631. .toggle.on::after { transform: translateX(18px); }
  632. .toggle.busy { opacity: .55; pointer-events: none; }
  633. .toggle-state {
  634. min-width: 52px;
  635. text-align: right;
  636. font-size: 11px;
  637. color: var(--text-dim);
  638. text-transform: uppercase;
  639. letter-spacing: 1px;
  640. }
  641. .rds-grid {
  642. display: grid;
  643. gap: 12px;
  644. }
  645. .rds-field {
  646. display: flex;
  647. flex-direction: column;
  648. gap: 6px;
  649. }
  650. .rds-input {
  651. width: 100%;
  652. background: var(--bg);
  653. border: 1px solid var(--border);
  654. border-radius: 6px;
  655. color: var(--green);
  656. font-family: var(--mono);
  657. font-size: 15px;
  658. font-weight: 700;
  659. padding: 10px 12px;
  660. outline: none;
  661. letter-spacing: 2px;
  662. text-transform: uppercase;
  663. }
  664. .rds-input.rt {
  665. color: var(--text);
  666. text-transform: none;
  667. letter-spacing: .5px;
  668. font-size: 12px;
  669. font-weight: 500;
  670. }
  671. .rds-charcount {
  672. font-size: 10px;
  673. color: var(--text-dim);
  674. text-align: right;
  675. }
  676. .actions-row {
  677. display: flex;
  678. gap: 10px;
  679. flex-wrap: wrap;
  680. margin-top: 14px;
  681. }
  682. .apply-btn {
  683. background: var(--accent);
  684. border-color: transparent;
  685. color: #fff;
  686. }
  687. .apply-btn.secondary {
  688. background: var(--surface2);
  689. color: var(--text-dim);
  690. border-color: var(--border);
  691. }
  692. .apply-btn.ok { background: var(--green); color: var(--bg); }
  693. .sidebar-card {
  694. padding: 14px;
  695. }
  696. .sidebar-section + .sidebar-section {
  697. margin-top: 14px;
  698. padding-top: 14px;
  699. border-top: 1px solid var(--border);
  700. }
  701. .sidebar-title {
  702. font-size: 11px;
  703. text-transform: uppercase;
  704. letter-spacing: 1.4px;
  705. color: var(--text-dim);
  706. margin-bottom: 10px;
  707. }
  708. .kv {
  709. display: grid;
  710. grid-template-columns: auto 1fr;
  711. gap: 8px 12px;
  712. align-items: start;
  713. }
  714. .kv .k {
  715. font-size: 10px;
  716. color: var(--text-muted);
  717. text-transform: uppercase;
  718. letter-spacing: 1px;
  719. }
  720. .kv .v {
  721. font-size: 12px;
  722. color: var(--text);
  723. word-break: break-word;
  724. }
  725. .health-line {
  726. display: flex;
  727. align-items: center;
  728. justify-content: space-between;
  729. gap: 10px;
  730. padding: 8px 0;
  731. border-bottom: 1px solid #1a1a22;
  732. }
  733. .health-line:last-child { border-bottom: none; }
  734. .health-line .name {
  735. font-size: 11px;
  736. color: var(--text-dim);
  737. }
  738. .health-line .val {
  739. font-size: 11px;
  740. color: var(--text);
  741. text-align: right;
  742. }
  743. .health-line .val.good { color: var(--green); }
  744. .health-line .val.warn { color: var(--amber); }
  745. .health-line .val.err { color: var(--accent); }
  746. .log {
  747. background: var(--bg);
  748. border: 1px solid var(--border);
  749. border-radius: 6px;
  750. padding: 10px;
  751. font-size: 10px;
  752. color: var(--text-dim);
  753. max-height: 220px;
  754. overflow-y: auto;
  755. white-space: pre-wrap;
  756. word-break: break-word;
  757. }
  758. .log .entry { padding: 3px 0; }
  759. .log .entry.err { color: var(--accent); }
  760. .log .entry.ok { color: var(--green); }
  761. .log .entry.warn { color: var(--amber); }
  762. .log .entry.info { color: var(--blue); }
  763. .empty-log {
  764. color: var(--text-muted);
  765. }
  766. .toast {
  767. position: fixed;
  768. right: 16px;
  769. bottom: 16px;
  770. max-width: min(420px, calc(100vw - 24px));
  771. padding: 12px 15px;
  772. border-radius: var(--radius);
  773. font-size: 12px;
  774. font-weight: 700;
  775. z-index: 2000;
  776. transform: translateY(60px);
  777. opacity: 0;
  778. transition: all .25s ease;
  779. box-shadow: var(--shadow);
  780. }
  781. .toast.show { transform: translateY(0); opacity: 1; }
  782. .toast.ok { background: var(--green); color: var(--bg); }
  783. .toast.err { background: var(--accent); color: #fff; }
  784. .toast.info { background: var(--blue); color: #fff; }
  785. .toast.warn { background: var(--amber); color: #141414; }
  786. @media (max-width: 980px) {
  787. .layout { grid-template-columns: 1fr; }
  788. .tx-bar {
  789. grid-template-columns: 1fr;
  790. align-items: stretch;
  791. }
  792. .tx-state-wrap {
  793. align-items: flex-start;
  794. }
  795. .status-hint { text-align: left; }
  796. .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  797. .signal-grid { grid-template-columns: 1fr; }
  798. }
  799. @media (max-width: 640px) {
  800. .app { padding: 12px; }
  801. .header { flex-direction: column; align-items: stretch; gap: 10px; }
  802. .header h1 { font-size: 22px; }
  803. .header-sub { gap: 6px; }
  804. .badge { width: 100%; justify-content: space-between; }
  805. .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
  806. .quick-item { padding: 10px; }
  807. .quick-item .value { font-size: 16px; }
  808. .ctrl-row { flex-direction: column; align-items: stretch; }
  809. .ctrl-label-wrap { min-width: auto; }
  810. .ctrl-input { flex-wrap: wrap; }
  811. input[type="number"] { width: 100%; text-align: left; }
  812. .actions-row, .tx-actions { flex-direction: column; }
  813. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; }
  814. .panel-head { padding: 11px 12px; }
  815. .panel-body, .sidebar-card { padding: 12px; }
  816. .freq-display { font-size: 31px; }
  817. .preset-row { flex-direction: column; }
  818. .shortcuts-grid { grid-template-columns: 1fr; }
  819. }
  820. </style>
  821. </head>
  822. <body>
  823. <div class="app">
  824. <div class="header">
  825. <div class="header-main">
  826. <h1>FM-RDS-TX</h1>
  827. <div class="header-sub">
  828. <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div>
  829. <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div>
  830. <div class="badge"><span>Live Config</span><strong id="badge-live">--</strong></div>
  831. </div>
  832. </div>
  833. <div class="header-status">
  834. <div class="led" id="led-conn"></div>
  835. <div class="status-text" id="conn-label">connecting</div>
  836. </div>
  837. </div>
  838. <div class="layout">
  839. <div class="stack">
  840. <div class="card hero" id="hero-card">
  841. <div class="tx-bar">
  842. <div class="freq-display-wrap">
  843. <div class="freq-display-label">Carrier</div>
  844. <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
  845. </div>
  846. <div class="tx-actions">
  847. <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
  848. <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
  849. <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
  850. </div>
  851. <div class="tx-state-wrap">
  852. <div class="tx-state idle" id="tx-state">IDLE</div>
  853. <div class="status-hint" id="tx-hint">Awaiting runtime data</div>
  854. </div>
  855. </div>
  856. <div class="quick-grid">
  857. <div class="quick-item">
  858. <div class="label">Chunks</div>
  859. <div class="value" id="t-chunks">--</div>
  860. </div>
  861. <div class="quick-item">
  862. <div class="label">Samples</div>
  863. <div class="value" id="t-samples">--</div>
  864. </div>
  865. <div class="quick-item">
  866. <div class="label">Underruns</div>
  867. <div class="value" id="t-underruns">--</div>
  868. </div>
  869. <div class="quick-item">
  870. <div class="label">Uptime</div>
  871. <div class="value" id="t-uptime">--</div>
  872. </div>
  873. <div class="quick-item">
  874. <div class="label">Rate</div>
  875. <div class="value" id="t-rate">--</div>
  876. </div>
  877. </div>
  878. <div class="signal-grid">
  879. <div class="signal-card">
  880. <div class="signal-head">
  881. <div class="signal-title">Audio Buffer</div>
  882. <div class="signal-value" id="meter-audio-text">--</div>
  883. </div>
  884. <div class="meter"><div class="meter-fill" id="meter-audio-fill"></div></div>
  885. <svg class="spark good" id="spark-audio" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  886. </div>
  887. <div class="signal-card">
  888. <div class="signal-head">
  889. <div class="signal-title">Stream Health</div>
  890. <div class="signal-value" id="meter-stream-text">--</div>
  891. </div>
  892. <div class="meter"><div class="meter-fill" id="meter-stream-fill"></div></div>
  893. <svg class="spark warn" id="spark-underruns" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  894. </div>
  895. <div class="signal-card">
  896. <div class="signal-head">
  897. <div class="signal-title">TX Activity</div>
  898. <div class="signal-value" id="meter-tx-text">--</div>
  899. </div>
  900. <div class="meter"><div class="meter-fill" id="meter-tx-fill"></div></div>
  901. <svg class="spark good" id="spark-tx" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  902. </div>
  903. </div>
  904. </div>
  905. <div class="card panel" data-panel-key="frequency">
  906. <div class="panel-head" data-panel>
  907. <div class="led on-green" style="width:6px;height:6px"></div>
  908. <h2>Frequency</h2>
  909. <div class="meta" id="freq-meta">Live-tunable</div>
  910. <span class="chevron">▼</span>
  911. </div>
  912. <div class="panel-body">
  913. <div class="section-note">Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.</div>
  914. <div class="preset-row" id="freq-presets">
  915. <button class="preset-btn" type="button" data-freq-preset="87.6">87.6 MHz</button>
  916. <button class="preset-btn" type="button" data-freq-preset="94.5">94.5 MHz</button>
  917. <button class="preset-btn" type="button" data-freq-preset="99.5">99.5 MHz</button>
  918. <button class="preset-btn" type="button" data-freq-preset="100.0">100.0 MHz</button>
  919. <button class="preset-btn" type="button" data-freq-preset="107.9">107.9 MHz</button>
  920. </div>
  921. <div class="ctrl-row">
  922. <div class="ctrl-label-wrap">
  923. <span class="ctrl-label">TX Freq</span>
  924. <span class="ctrl-sub">Valid range 65–110 MHz</span>
  925. </div>
  926. <div class="ctrl-input">
  927. <input type="range" min="65" max="110" step="0.1" id="freq-slider">
  928. <input type="number" min="65" max="110" step="0.1" id="freq-num">
  929. <span class="unit-label">MHz</span>
  930. </div>
  931. </div>
  932. <div class="field-error" id="freq-error"></div>
  933. <div class="actions-row">
  934. <button class="apply-btn" id="freq-apply" type="button">Apply Frequency</button>
  935. <button class="apply-btn secondary" id="freq-reset" type="button">Reset</button>
  936. </div>
  937. </div>
  938. </div>
  939. <div class="card panel" data-panel-key="switches">
  940. <div class="panel-head" data-panel>
  941. <div class="led on-green" style="width:6px;height:6px"></div>
  942. <h2>Switches</h2>
  943. <div class="meta">Live</div>
  944. <span class="chevron">▼</span>
  945. </div>
  946. <div class="panel-body">
  947. <div class="section-note">These switches apply immediately and show a busy state while the request is in flight.</div>
  948. <div class="toggle-row">
  949. <div class="toggle-copy">
  950. <div class="title">Stereo</div>
  951. <div class="sub">19 kHz pilot + 38 kHz DSB-SC</div>
  952. </div>
  953. <div class="toggle-ctl">
  954. <div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  955. <div class="toggle-state" id="stereo-label">--</div>
  956. </div>
  957. </div>
  958. <div class="toggle-row">
  959. <div class="toggle-copy">
  960. <div class="title">RDS</div>
  961. <div class="sub">57 kHz subcarrier encoder</div>
  962. </div>
  963. <div class="toggle-ctl">
  964. <div class="toggle" id="tog-rds" data-toggle="rdsEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  965. <div class="toggle-state" id="rds-label">--</div>
  966. </div>
  967. </div>
  968. <div class="toggle-row">
  969. <div class="toggle-copy">
  970. <div class="title">Limiter</div>
  971. <div class="sub">MPX peak protection</div>
  972. </div>
  973. <div class="toggle-ctl">
  974. <div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  975. <div class="toggle-state" id="limiter-label">--</div>
  976. </div>
  977. </div>
  978. </div>
  979. </div>
  980. <div class="card panel" data-panel-key="rds">
  981. <div class="panel-head" data-panel>
  982. <div class="led on-amber" style="width:6px;height:6px"></div>
  983. <h2>RDS Text</h2>
  984. <div class="meta" id="rds-meta">PS + RT</div>
  985. <span class="chevron">▼</span>
  986. </div>
  987. <div class="panel-body">
  988. <div class="section-note">Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.</div>
  989. <div class="preset-row" id="rds-presets">
  990. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  991. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  992. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  993. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  994. </div>
  995. <div class="rds-grid">
  996. <div class="rds-field">
  997. <span class="ctrl-label">Program Service (PS)</span>
  998. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  999. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  1000. <div class="field-error" id="ps-error"></div>
  1001. </div>
  1002. <div class="rds-field">
  1003. <span class="ctrl-label">RadioText (RT)</span>
  1004. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  1005. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  1006. <div class="field-error" id="rt-error"></div>
  1007. </div>
  1008. </div>
  1009. <div class="actions-row">
  1010. <button class="apply-btn" id="rds-apply" type="button">Apply RDS Text</button>
  1011. <button class="apply-btn secondary" id="rds-reset" type="button">Reset</button>
  1012. </div>
  1013. </div>
  1014. </div>
  1015. </div>
  1016. <div class="stack">
  1017. <div class="card sidebar-card">
  1018. <div class="sidebar-section">
  1019. <div class="sidebar-title">System Snapshot</div>
  1020. <div class="kv">
  1021. <div class="k">Backend</div><div class="v" id="info-backend">--</div>
  1022. <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
  1023. <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
  1024. <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div>
  1025. <div class="k">Live Config</div><div class="v" id="info-live">--</div>
  1026. </div>
  1027. </div>
  1028. <div class="sidebar-section">
  1029. <div class="sidebar-title">Health</div>
  1030. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  1031. <div class="health-line"><div class="name">Runtime</div><div class="val" id="health-runtime">--</div></div>
  1032. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  1033. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  1034. <div class="health-line"><div class="name">Transitions (D/M/F)</div><div class="val" id="health-transitions">--</div></div>
  1035. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  1036. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  1037. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  1038. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  1039. </div>
  1040. </div>
  1041. <div class="card panel" data-panel-key="shortcuts">
  1042. <div class="panel-head" data-panel>
  1043. <h2>Shortcuts</h2>
  1044. <div class="meta">keyboard</div>
  1045. <span class="chevron">▼</span>
  1046. </div>
  1047. <div class="panel-body">
  1048. <div class="section-note">Fast control, as long as you're not typing in an input field.</div>
  1049. <div class="shortcuts-grid">
  1050. <div>
  1051. <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div>
  1052. <div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">Shift</span><span class="kbd">t</span></span></div>
  1053. <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div>
  1054. </div>
  1055. <div>
  1056. <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div>
  1057. <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div>
  1058. <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div>
  1059. </div>
  1060. </div>
  1061. </div>
  1062. </div>
  1063. <div class="card panel" data-panel-key="danger">
  1064. <div class="panel-head" data-panel>
  1065. <h2>Danger Zone</h2>
  1066. <div class="meta">tx control</div>
  1067. <span class="chevron">▼</span>
  1068. </div>
  1069. <div class="panel-body">
  1070. <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div>
  1071. <div class="actions-row" style="margin-top:0">
  1072. <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button>
  1073. <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button>
  1074. <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button>
  1075. </div>
  1076. </div>
  1077. </div>
  1078. <div class="card panel" data-panel-key="log">
  1079. <div class="panel-head" data-panel>
  1080. <h2>Activity Log</h2>
  1081. <div class="meta" id="log-meta">recent events</div>
  1082. <span class="chevron">▼</span>
  1083. </div>
  1084. <div class="panel-body">
  1085. <div class="actions-row" style="margin-top:0;margin-bottom:12px">
  1086. <button class="ghost-btn" id="btn-clear-log" type="button">Clear Log</button>
  1087. </div>
  1088. <div class="log" id="log"><div class="empty-log">No events yet.</div></div>
  1089. </div>
  1090. </div>
  1091. </div>
  1092. </div>
  1093. </div>
  1094. <div class="toast" id="toast"></div>
  1095. <script>
  1096. const $ = (id) => document.getElementById(id);
  1097. const runtimePollMs = 1000;
  1098. const configPollMs = 8000;
  1099. const mobileMq = window.matchMedia('(max-width: 640px)');
  1100. const freqPresetValues = [87.6, 94.5, 99.5, 100.0, 107.9];
  1101. const sparkHistoryLimit = 40;
  1102. const state = {
  1103. server: {
  1104. config: null,
  1105. runtime: null,
  1106. lastConfigAt: 0,
  1107. lastRuntimeAt: 0,
  1108. configOk: false,
  1109. runtimeOk: false,
  1110. },
  1111. draft: {
  1112. frequencyMHz: undefined,
  1113. ps: undefined,
  1114. radioText: undefined,
  1115. },
  1116. errors: {
  1117. frequencyMHz: '',
  1118. ps: '',
  1119. radioText: '',
  1120. },
  1121. dirty: new Set(),
  1122. pendingRequests: 0,
  1123. txBusy: false,
  1124. faultResetBusy: false,
  1125. toggleBusy: {},
  1126. pollersStarted: false,
  1127. mobilePanelsApplied: false,
  1128. charts: {
  1129. audio: [],
  1130. underruns: [],
  1131. tx: [],
  1132. },
  1133. freqPresetIndex: 0,
  1134. };
  1135. const fields = {
  1136. frequencyMHz: { section: 'freq', equal: (a,b) => nearlyEqual(a,b,0.0001) },
  1137. ps: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1138. radioText: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1139. };
  1140. function nearlyEqual(a, b, eps = 1e-9) {
  1141. if (a == null && b == null) return true;
  1142. if (a == null || b == null) return false;
  1143. return Math.abs(Number(a) - Number(b)) <= eps;
  1144. }
  1145. function nowTs() { return Date.now(); }
  1146. function serverValue(key) {
  1147. const cfg = state.server.config;
  1148. if (!cfg) return undefined;
  1149. switch (key) {
  1150. case 'frequencyMHz': return cfg.fm?.frequencyMHz;
  1151. case 'ps': return cfg.rds?.ps ?? '';
  1152. case 'radioText': return cfg.rds?.radioText ?? '';
  1153. case 'stereoEnabled': return cfg.fm?.stereoEnabled;
  1154. case 'rdsEnabled': return cfg.rds?.enabled;
  1155. case 'limiterEnabled': return cfg.fm?.limiterEnabled;
  1156. default: return undefined;
  1157. }
  1158. }
  1159. function effectiveValue(key) {
  1160. return state.dirty.has(key) ? state.draft[key] : serverValue(key);
  1161. }
  1162. function validateField(key, value) {
  1163. switch (key) {
  1164. case 'frequencyMHz': {
  1165. if (value == null || Number.isNaN(Number(value))) return 'Enter a valid number.';
  1166. const num = Number(value);
  1167. if (num < 65 || num > 110) return 'Frequency must stay between 65 and 110 MHz.';
  1168. return '';
  1169. }
  1170. case 'ps': {
  1171. const text = String(value ?? '');
  1172. if (text.length > 8) return 'PS is limited to 8 characters.';
  1173. return '';
  1174. }
  1175. case 'radioText': {
  1176. const text = String(value ?? '');
  1177. if (text.length > 64) return 'RadioText is limited to 64 characters.';
  1178. return '';
  1179. }
  1180. default:
  1181. return '';
  1182. }
  1183. }
  1184. function sectionHasErrors(section) {
  1185. return Object.entries(fields).some(([key, meta]) => meta.section === section && state.errors[key]);
  1186. }
  1187. function setDirty(key, value) {
  1188. state.draft[key] = value;
  1189. state.errors[key] = validateField(key, value);
  1190. const current = serverValue(key);
  1191. const equal = !state.errors[key] && fields[key].equal(value, current);
  1192. if (equal) {
  1193. state.dirty.delete(key);
  1194. state.draft[key] = undefined;
  1195. } else {
  1196. state.dirty.add(key);
  1197. }
  1198. if (key === 'frequencyMHz' && typeof value === 'number') syncFreqPresetIndex(value);
  1199. render();
  1200. }
  1201. function clearDirty(keys) {
  1202. for (const key of keys) {
  1203. state.dirty.delete(key);
  1204. state.draft[key] = undefined;
  1205. state.errors[key] = '';
  1206. }
  1207. render();
  1208. }
  1209. function isDirtySection(section) {
  1210. for (const key of state.dirty) {
  1211. if (fields[key]?.section === section) return true;
  1212. }
  1213. return false;
  1214. }
  1215. function getSectionPatch(section) {
  1216. const patch = {};
  1217. for (const key of state.dirty) {
  1218. if (fields[key]?.section === section && !state.errors[key]) patch[key] = state.draft[key];
  1219. }
  1220. return patch;
  1221. }
  1222. async function api(path, opts) {
  1223. const response = await fetch(path, opts);
  1224. const text = await response.text();
  1225. if (!response.ok) {
  1226. throw new Error(text.trim() || `HTTP ${response.status}`);
  1227. }
  1228. if (!text) return {};
  1229. try {
  1230. return JSON.parse(text);
  1231. } catch {
  1232. return { ok: true, raw: text };
  1233. }
  1234. }
  1235. function setConnection(ok, mode) {
  1236. const led = $('led-conn');
  1237. const label = $('conn-label');
  1238. if (ok) {
  1239. led.className = 'led on-green';
  1240. label.textContent = mode || 'connected';
  1241. } else {
  1242. led.className = 'led on-red';
  1243. label.textContent = mode || 'offline';
  1244. }
  1245. }
  1246. async function loadConfig({ silent = false } = {}) {
  1247. try {
  1248. const cfg = await api('/config');
  1249. state.server.config = cfg;
  1250. state.server.configOk = true;
  1251. state.server.lastConfigAt = nowTs();
  1252. syncFreqPresetIndex(cfg.fm?.frequencyMHz);
  1253. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1254. render();
  1255. if (!silent) log('Config synced from server', 'info');
  1256. return cfg;
  1257. } catch (error) {
  1258. state.server.configOk = false;
  1259. if (!state.server.runtimeOk) setConnection(false, 'offline');
  1260. render();
  1261. if (!silent) log('Config load failed: ' + error.message, 'err');
  1262. throw error;
  1263. }
  1264. }
  1265. async function loadRuntime({ silent = true } = {}) {
  1266. try {
  1267. const runtime = await api('/runtime');
  1268. state.server.runtime = runtime;
  1269. state.server.runtimeOk = true;
  1270. state.server.lastRuntimeAt = nowTs();
  1271. pushHistory(runtime);
  1272. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1273. render();
  1274. return runtime;
  1275. } catch (error) {
  1276. state.server.runtimeOk = false;
  1277. if (!state.server.configOk) setConnection(false, 'offline');
  1278. render();
  1279. if (!silent) log('Runtime load failed: ' + error.message, 'err');
  1280. throw error;
  1281. }
  1282. }
  1283. function pushHistory(runtime) {
  1284. const engine = runtime.engine || {};
  1285. const driver = runtime.driver || {};
  1286. const audio = runtime.audioStream || {};
  1287. pushChart(state.charts.audio, typeof audio.buffered === 'number' ? audio.buffered : 0);
  1288. pushChart(state.charts.underruns, Number(engine.underruns ?? driver.underruns ?? 0));
  1289. const txState = String(engine.state || 'idle').toLowerCase();
  1290. pushChart(state.charts.tx, txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.05);
  1291. }
  1292. function pushChart(arr, value) {
  1293. arr.push(Number.isFinite(value) ? value : 0);
  1294. if (arr.length > sparkHistoryLimit) arr.splice(0, arr.length - sparkHistoryLimit);
  1295. }
  1296. function setConnectionBusy() {
  1297. setConnection(true, 'busy');
  1298. }
  1299. function beginRequest() {
  1300. state.pendingRequests += 1;
  1301. setConnectionBusy();
  1302. render();
  1303. }
  1304. function endRequest() {
  1305. state.pendingRequests = Math.max(0, state.pendingRequests - 1);
  1306. if (state.server.configOk || state.server.runtimeOk) {
  1307. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1308. }
  1309. render();
  1310. }
  1311. async function sendPatch(patch, { successMessage = 'Applied', clearKeys = [] } = {}) {
  1312. beginRequest();
  1313. try {
  1314. const result = await api('/config', {
  1315. method: 'POST',
  1316. headers: { 'Content-Type': 'application/json' },
  1317. body: JSON.stringify(patch),
  1318. });
  1319. if (state.server.config) mergeLocalConfigPatch(patch);
  1320. clearDirty(clearKeys);
  1321. render();
  1322. toast(successMessage + (result.live ? ' · live' : ''), 'ok');
  1323. log('PATCH ' + JSON.stringify(patch) + (result.live ? ' [live]' : ' [saved]'), 'ok');
  1324. await Promise.allSettled([
  1325. loadConfig({ silent: true }),
  1326. loadRuntime({ silent: true }),
  1327. ]);
  1328. return result;
  1329. } catch (error) {
  1330. toast(error.message, 'err');
  1331. log('PATCH failed: ' + error.message, 'err');
  1332. throw error;
  1333. } finally {
  1334. endRequest();
  1335. }
  1336. }
  1337. function mergeLocalConfigPatch(patch) {
  1338. const cfg = state.server.config || {};
  1339. cfg.fm ||= {};
  1340. cfg.rds ||= {};
  1341. for (const [key, value] of Object.entries(patch)) {
  1342. switch (key) {
  1343. case 'frequencyMHz': cfg.fm.frequencyMHz = value; break;
  1344. case 'stereoEnabled': cfg.fm.stereoEnabled = value; break;
  1345. case 'limiterEnabled': cfg.fm.limiterEnabled = value; break;
  1346. case 'rdsEnabled': cfg.rds.enabled = value; break;
  1347. case 'ps': cfg.rds.ps = value; break;
  1348. case 'radioText': cfg.rds.radioText = value; break;
  1349. }
  1350. }
  1351. }
  1352. async function applySection(section) {
  1353. if (sectionHasErrors(section)) {
  1354. toast('Fix the highlighted fields first', 'warn');
  1355. log(section.toUpperCase() + ' apply blocked by validation errors', 'warn');
  1356. render();
  1357. return;
  1358. }
  1359. const patch = getSectionPatch(section);
  1360. const keys = Object.keys(patch);
  1361. if (!keys.length) {
  1362. toast('No changes to apply', 'info');
  1363. return;
  1364. }
  1365. let message = 'Applied';
  1366. if (section === 'freq') message = 'Frequency updated';
  1367. if (section === 'rds') message = 'RDS text updated';
  1368. await sendPatch(patch, { successMessage: message, clearKeys: keys });
  1369. }
  1370. function resetSection(section) {
  1371. const keys = Object.keys(fields).filter((key) => fields[key].section === section);
  1372. clearDirty(keys);
  1373. log(section.toUpperCase() + ' draft reset', 'warn');
  1374. toast('Draft reset', 'info');
  1375. }
  1376. async function setToggle(key, nextValue) {
  1377. if (state.toggleBusy[key]) return;
  1378. state.toggleBusy[key] = true;
  1379. render();
  1380. try {
  1381. await sendPatch({ [key]: nextValue }, {
  1382. successMessage: key.replace(/Enabled$/, '') + ' ' + (nextValue ? 'enabled' : 'disabled'),
  1383. clearKeys: [],
  1384. });
  1385. } finally {
  1386. state.toggleBusy[key] = false;
  1387. render();
  1388. }
  1389. }
  1390. async function txAction(action) {
  1391. if (state.txBusy) return;
  1392. state.txBusy = true;
  1393. render();
  1394. beginRequest();
  1395. try {
  1396. await api(`/tx/${action}`, { method: 'POST' });
  1397. toast(action === 'start' ? 'TX started' : 'TX stopped', 'ok');
  1398. log('TX ' + action + ' request accepted', 'ok');
  1399. await Promise.allSettled([
  1400. loadRuntime({ silent: true }),
  1401. loadConfig({ silent: true }),
  1402. ]);
  1403. } catch (error) {
  1404. toast(error.message, 'err');
  1405. log('TX ' + action + ' failed: ' + error.message, 'err');
  1406. } finally {
  1407. state.txBusy = false;
  1408. endRequest();
  1409. render();
  1410. }
  1411. }
  1412. async function resetFaultAction() {
  1413. if (state.faultResetBusy) return;
  1414. state.faultResetBusy = true;
  1415. render();
  1416. beginRequest();
  1417. try {
  1418. await api('/runtime/fault/reset', { method: 'POST' });
  1419. toast('Fault reset', 'ok');
  1420. log('Fault reset request accepted', 'ok');
  1421. await loadRuntime({ silent: true });
  1422. } catch (error) {
  1423. toast(error.message, 'err');
  1424. log('Fault reset failed: ' + error.message, 'err');
  1425. } finally {
  1426. state.faultResetBusy = false;
  1427. endRequest();
  1428. render();
  1429. }
  1430. }
  1431. function fmt(n) {
  1432. if (n == null) return '--';
  1433. if (n >= 1e9) return (n / 1e9).toFixed(2) + 'G';
  1434. if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
  1435. if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
  1436. return String(n);
  1437. }
  1438. function fmtTime(seconds) {
  1439. if (!seconds || seconds <= 0) return '--';
  1440. const h = Math.floor(seconds / 3600);
  1441. const m = Math.floor((seconds % 3600) / 60);
  1442. const s = Math.floor(seconds % 60);
  1443. if (h > 0) return `${h}h ${m}m`;
  1444. if (m > 0) return `${m}m ${s}s`;
  1445. return `${s}s`;
  1446. }
  1447. function fmtBool(v) {
  1448. return v == null ? '--' : (v ? 'ON' : 'OFF');
  1449. }
  1450. function fmtFreq(v) {
  1451. return typeof v === 'number' ? v.toFixed(1) + ' MHz' : '--';
  1452. }
  1453. function fmtPercent(v) {
  1454. if (typeof v !== 'number') return '--';
  1455. return (v * 100).toFixed(0) + '%';
  1456. }
  1457. function ageString(ts) {
  1458. if (!ts) return '--';
  1459. const diff = Math.max(0, Math.floor((nowTs() - ts) / 1000));
  1460. if (diff < 2) return 'just now';
  1461. if (diff < 60) return diff + 's ago';
  1462. const m = Math.floor(diff / 60);
  1463. if (m < 60) return m + 'm ago';
  1464. const h = Math.floor(m / 60);
  1465. return h + 'h ago';
  1466. }
  1467. function updateText(id, text) {
  1468. const el = $(id);
  1469. if (el && el.textContent !== String(text)) el.textContent = text;
  1470. }
  1471. function updateHTML(id, html) {
  1472. const el = $(id);
  1473. if (el && el.innerHTML !== html) el.innerHTML = html;
  1474. }
  1475. function syncDirtyInput(id, key, transform = (v) => v) {
  1476. const el = $(id);
  1477. if (!el) return;
  1478. const dirty = state.dirty.has(key);
  1479. const desired = dirty ? transform(state.draft[key]) : transform(serverValue(key));
  1480. const asString = desired == null ? '' : String(desired);
  1481. const isFocused = document.activeElement === el;
  1482. if (!isFocused || !dirty) {
  1483. if (el.value !== asString) el.value = asString;
  1484. }
  1485. el.classList.toggle('input-dirty', dirty && !state.errors[key]);
  1486. el.classList.toggle('input-error', !!state.errors[key]);
  1487. }
  1488. function renderFieldErrors() {
  1489. renderFieldError('freq-error', state.errors.frequencyMHz);
  1490. renderFieldError('ps-error', state.errors.ps);
  1491. renderFieldError('rt-error', state.errors.radioText);
  1492. }
  1493. function renderFieldError(id, message) {
  1494. const el = $(id);
  1495. el.textContent = message || '';
  1496. el.classList.toggle('show', !!message);
  1497. }
  1498. function setMeter(fillId, textId, ratio, text, mode = 'good') {
  1499. const fill = $(fillId);
  1500. const pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100)));
  1501. fill.style.width = pct + '%';
  1502. fill.className = 'meter-fill' + (mode === 'warn' ? ' warn' : mode === 'err' ? ' err' : '');
  1503. updateText(textId, text);
  1504. }
  1505. function applyMobilePanelDefaults() {
  1506. if (!mobileMq.matches || state.mobilePanelsApplied) return;
  1507. state.mobilePanelsApplied = true;
  1508. document.querySelectorAll('.panel[data-panel-key]').forEach((panel) => {
  1509. const key = panel.dataset.panelKey;
  1510. const head = panel.querySelector('.panel-head');
  1511. const body = panel.querySelector('.panel-body');
  1512. const keepOpen = key === 'frequency' || key === 'danger';
  1513. head.classList.toggle('collapsed', !keepOpen);
  1514. body.classList.toggle('collapsed', !keepOpen);
  1515. });
  1516. }
  1517. function syncFreqPresetIndex(value) {
  1518. if (typeof value !== 'number') return;
  1519. let closestIndex = 0;
  1520. let closestDistance = Infinity;
  1521. freqPresetValues.forEach((freq, idx) => {
  1522. const dist = Math.abs(freq - value);
  1523. if (dist < closestDistance) {
  1524. closestDistance = dist;
  1525. closestIndex = idx;
  1526. }
  1527. });
  1528. state.freqPresetIndex = closestIndex;
  1529. }
  1530. function cycleFreqPreset(direction) {
  1531. state.freqPresetIndex = (state.freqPresetIndex + direction + freqPresetValues.length) % freqPresetValues.length;
  1532. const next = freqPresetValues[state.freqPresetIndex];
  1533. setDirty('frequencyMHz', next);
  1534. toast(`Preset ${next.toFixed(1)} MHz loaded`, 'info');
  1535. }
  1536. function refreshPresetButtons() {
  1537. const effectiveFreq = effectiveValue('frequencyMHz');
  1538. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  1539. const value = Number(btn.dataset.freqPreset);
  1540. btn.classList.toggle('active', typeof effectiveFreq === 'number' && nearlyEqual(value, effectiveFreq, 0.05));
  1541. });
  1542. }
  1543. function updateHeroState(engineState) {
  1544. const hero = $('hero-card');
  1545. hero.classList.remove('tx-live', 'tx-busy');
  1546. if (state.txBusy || ['starting', 'stopping'].includes(engineState)) {
  1547. hero.classList.add('tx-busy');
  1548. } else if (engineState === 'running') {
  1549. hero.classList.add('tx-live');
  1550. }
  1551. }
  1552. function drawSparkline(svgId, values, mode = 'good', maxOverride = null) {
  1553. const svg = $(svgId);
  1554. if (!svg) return;
  1555. svg.className = `spark ${mode}`;
  1556. const width = 160;
  1557. const height = 34;
  1558. const points = values.length ? values : [0, 0];
  1559. const max = maxOverride != null ? maxOverride : Math.max(...points, 1);
  1560. const min = 0;
  1561. const step = points.length <= 1 ? width : width / (points.length - 1);
  1562. const coords = points.map((value, i) => {
  1563. const x = i * step;
  1564. const norm = max === min ? 0 : (value - min) / (max - min || 1);
  1565. const y = height - 4 - norm * (height - 8);
  1566. return [x, y];
  1567. });
  1568. const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
  1569. const area = `${line} L ${width},${height} L 0,${height} Z`;
  1570. svg.innerHTML = `<path class="area" d="${area}"></path><path class="line" d="${line}"></path>`;
  1571. }
  1572. function render() {
  1573. const cfg = state.server.config || {};
  1574. const runtime = state.server.runtime || {};
  1575. const engine = runtime.engine || {};
  1576. const driver = runtime.driver || {};
  1577. const audioStream = runtime.audioStream || null;
  1578. const freq = effectiveValue('frequencyMHz') ?? cfg.fm?.frequencyMHz;
  1579. updateHTML('freq-display', `${typeof freq === 'number' ? freq.toFixed(1) : '---.-'}<span class="unit">MHz</span>`);
  1580. updateText('badge-backend', cfg.backend?.kind || cfg.backend || '--');
  1581. updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane');
  1582. updateText('badge-live', state.server.runtimeOk ? 'Connected' : 'Waiting');
  1583. updateText('t-chunks', fmt(engine.chunksProduced));
  1584. updateText('t-samples', fmt(engine.totalSamples));
  1585. updateText('t-uptime', fmtTime(engine.uptimeSeconds));
  1586. updateText('t-rate', driver.effectiveSampleRateHz ? (driver.effectiveSampleRateHz / 1000).toFixed(0) + 'k' : '--');
  1587. const underruns = engine.underruns ?? driver.underruns;
  1588. const underrunEl = $('t-underruns');
  1589. underrunEl.textContent = underruns == null ? '--' : String(underruns);
  1590. underrunEl.className = 'value' + (underruns > 0 ? ' err' : underruns === 0 ? ' good' : '');
  1591. const txStateValue = String(engine.state || 'idle').toLowerCase();
  1592. const txState = state.txBusy ? 'WORKING…' : txStateValue.toUpperCase();
  1593. const txClass = state.txBusy ? 'working' : txStateValue;
  1594. $('tx-state').textContent = txState;
  1595. $('tx-state').className = 'tx-state ' + txClass;
  1596. updateText('tx-hint', engine.lastError ? `Last error: ${engine.lastError}` : (state.txBusy ? 'Command in progress' : 'Runtime polled every 1s'));
  1597. updateHeroState(txStateValue);
  1598. const startDisabled = state.txBusy || txStateValue === 'running';
  1599. const stopDisabled = state.txBusy || ['idle', 'stopped', ''].includes(txStateValue);
  1600. $('btn-start').disabled = startDisabled;
  1601. $('btn-stop').disabled = stopDisabled;
  1602. $('btn-refresh').disabled = state.pendingRequests > 0;
  1603. $('danger-stop').disabled = stopDisabled;
  1604. $('danger-refresh').disabled = state.pendingRequests > 0;
  1605. const resetFaultBtn = $('danger-reset-fault');
  1606. if (resetFaultBtn) {
  1607. const resetDisabled = state.faultResetBusy || !state.server.runtimeOk;
  1608. resetFaultBtn.disabled = resetDisabled;
  1609. resetFaultBtn.textContent = state.faultResetBusy ? 'Resetting…' : 'Reset Fault';
  1610. }
  1611. syncDirtyInput('freq-slider', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  1612. syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  1613. syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? ''));
  1614. syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? ''));
  1615. const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? '');
  1616. const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? '');
  1617. updateText('ps-count', psValue.length);
  1618. updateText('rt-count', rtValue.length);
  1619. renderFieldErrors();
  1620. refreshPresetButtons();
  1621. renderToggle('stereoEnabled', 'tog-stereo', 'stereo-label');
  1622. renderToggle('rdsEnabled', 'tog-rds', 'rds-label');
  1623. renderToggle('limiterEnabled', 'tog-limiter', 'limiter-label');
  1624. const freqDirty = isDirtySection('freq');
  1625. $('freq-apply').disabled = !freqDirty || sectionHasErrors('freq');
  1626. $('freq-reset').disabled = !freqDirty;
  1627. const rdsDirty = isDirtySection('rds');
  1628. $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds');
  1629. $('rds-reset').disabled = !rdsDirty;
  1630. updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable'));
  1631. updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT'));
  1632. updateText('info-backend', cfg.backend?.kind || cfg.backend || '--');
  1633. updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz));
  1634. updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off');
  1635. updateText('info-fmmod', fmtBool(cfg.fm?.fmModulationEnabled));
  1636. updateText('info-live', engine.state ? `${String(engine.state).toUpperCase()} / ${state.server.runtimeOk ? 'runtime ok' : 'runtime pending'}` : (state.server.configOk ? 'config only' : '--'));
  1637. updateHealth(engine, audioStream);
  1638. updateMeters(engine, driver, audioStream);
  1639. drawSparkline('spark-audio', state.charts.audio, 'good', 1);
  1640. drawSparkline('spark-underruns', state.charts.underruns, underruns > 0 ? 'err' : 'warn');
  1641. drawSparkline('spark-tx', state.charts.tx, txStateValue === 'running' ? 'good' : 'warn', 1);
  1642. applyMobilePanelDefaults();
  1643. }
  1644. function renderToggle(key, toggleId, labelId) {
  1645. const on = !!serverValue(key);
  1646. const busy = !!state.toggleBusy[key];
  1647. const el = $(toggleId);
  1648. el.className = 'toggle' + (on ? ' on' : '') + (busy ? ' busy' : '');
  1649. el.setAttribute('aria-checked', on ? 'true' : 'false');
  1650. updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF'));
  1651. }
  1652. function runtimeStateClass(engineState) {
  1653. const normalized = String(engineState || '').toLowerCase();
  1654. if (!normalized) {
  1655. return 'warn';
  1656. }
  1657. switch (normalized) {
  1658. case 'faulted':
  1659. return 'err';
  1660. case 'muted':
  1661. case 'degraded':
  1662. case 'prebuffering':
  1663. case 'arming':
  1664. case 'stopping':
  1665. case 'idle':
  1666. case 'unknown':
  1667. return 'warn';
  1668. default:
  1669. return 'good';
  1670. }
  1671. }
  1672. function updateHealth(engine, audioStream) {
  1673. engine = engine || {};
  1674. updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE');
  1675. $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err');
  1676. let runtimeLabel = 'WAITING';
  1677. let runtimeClass = 'warn';
  1678. if (state.server.runtimeOk) {
  1679. const engineStateName = String(engine.state || 'unknown');
  1680. runtimeLabel = engineStateName.toUpperCase();
  1681. runtimeClass = runtimeStateClass(engineStateName);
  1682. }
  1683. updateText('health-runtime', runtimeLabel);
  1684. $('health-runtime').className = 'val ' + runtimeClass;
  1685. const runtimeIndicator = engine.runtimeIndicator;
  1686. const indicatorLabels = {
  1687. normal: 'Normal',
  1688. degraded: 'Degraded',
  1689. queueCritical: 'Queue critical',
  1690. };
  1691. const indicatorText = indicatorLabels[runtimeIndicator] || (runtimeIndicator ? runtimeIndicator : '--');
  1692. let indicatorSeverity = '';
  1693. if (runtimeIndicator === 'queueCritical') indicatorSeverity = 'err';
  1694. else if (runtimeIndicator === 'degraded') indicatorSeverity = 'warn';
  1695. else if (runtimeIndicator === 'normal') indicatorSeverity = 'good';
  1696. const indicatorEl = $('health-indicator');
  1697. if (indicatorEl) {
  1698. indicatorEl.className = 'val' + (indicatorSeverity ? ' ' + indicatorSeverity : '');
  1699. }
  1700. updateText('health-indicator', indicatorText);
  1701. const runtimeAlertRaw = (engine.runtimeAlert || '').trim();
  1702. const hasAlert = !!runtimeAlertRaw;
  1703. const alertEl = $('health-alert');
  1704. if (alertEl) {
  1705. alertEl.className = 'val ' + (hasAlert ? 'warn' : 'good');
  1706. }
  1707. updateText('health-alert', hasAlert ? runtimeAlertRaw : 'None');
  1708. let audioLabel = 'N/A';
  1709. let audioClass = 'val';
  1710. if (audioStream) {
  1711. const fill = typeof audioStream.buffered === 'number' ? audioStream.buffered : null;
  1712. audioLabel = fill == null ? 'Unknown' : fmtPercent(fill);
  1713. if (fill == null) audioClass = 'val';
  1714. else if (fill < 0.05) audioClass = 'val err';
  1715. else if (fill < 0.2) audioClass = 'val warn';
  1716. else audioClass = 'val good';
  1717. }
  1718. updateText('health-audio', audioLabel);
  1719. $('health-audio').className = audioClass;
  1720. const last = Math.max(state.server.lastConfigAt || 0, state.server.lastRuntimeAt || 0);
  1721. updateText('health-last', ageString(last));
  1722. const transitionsAvailable = engine.degradedTransitions != null || engine.mutedTransitions != null || engine.faultedTransitions != null;
  1723. const transitionsText = transitionsAvailable ? `${Number(engine.degradedTransitions ?? 0)} / ${Number(engine.mutedTransitions ?? 0)} / ${Number(engine.faultedTransitions ?? 0)}` : '--';
  1724. updateText('health-transitions', transitionsText);
  1725. const faultCountValue = engine.faultCount != null ? Number(engine.faultCount) : 0;
  1726. const hasFaultCount = engine.faultCount != null;
  1727. updateText('health-fault-count', hasFaultCount ? String(faultCountValue) : '--');
  1728. const faultCountEl = $('health-fault-count');
  1729. if (faultCountEl) {
  1730. faultCountEl.className = 'val' + (hasFaultCount ? (faultCountValue > 0 ? ' warn' : ' good') : '');
  1731. }
  1732. const lastFaultEl = $('health-last-fault');
  1733. const lastFault = engine.lastFault;
  1734. if (lastFaultEl) {
  1735. if (lastFault) {
  1736. const severity = String(lastFault.severity || '').toLowerCase();
  1737. const severityClass = severity === 'faulted' ? 'err' : 'warn';
  1738. const severityLabel = (lastFault.severity || 'Fault').toUpperCase();
  1739. const reasonLabel = lastFault.reason ? ` ${lastFault.reason}` : '';
  1740. const messageLabel = lastFault.message ? ` - ${lastFault.message}` : '';
  1741. let whenLabel = '';
  1742. if (lastFault.time) {
  1743. const parsed = new Date(lastFault.time);
  1744. if (!Number.isNaN(parsed.getTime())) {
  1745. whenLabel = ` @ ${parsed.toLocaleTimeString()}`;
  1746. }
  1747. }
  1748. const title = `${severityLabel}${reasonLabel}`;
  1749. updateText('health-last-fault', `${title}${messageLabel}${whenLabel}`);
  1750. lastFaultEl.className = 'val ' + severityClass;
  1751. } else {
  1752. lastFaultEl.className = 'val good';
  1753. updateText('health-last-fault', 'None');
  1754. }
  1755. }
  1756. }
  1757. function updateMeters(engine, driver, audioStream) {
  1758. if (audioStream && typeof audioStream.buffered === 'number') {
  1759. const ratio = Math.max(0, Math.min(1, audioStream.buffered));
  1760. const mode = ratio < 0.05 ? 'err' : ratio < 0.2 ? 'warn' : 'good';
  1761. setMeter('meter-audio-fill', 'meter-audio-text', ratio, fmtPercent(ratio), mode);
  1762. } else {
  1763. setMeter('meter-audio-fill', 'meter-audio-text', 0, 'N/A', 'warn');
  1764. }
  1765. const underruns = Number(engine.underruns ?? driver.underruns ?? 0);
  1766. const healthRatio = underruns <= 0 ? 1 : Math.max(0, 1 - Math.min(underruns, 10) / 10);
  1767. const healthMode = underruns === 0 ? 'good' : underruns < 3 ? 'warn' : 'err';
  1768. setMeter('meter-stream-fill', 'meter-stream-text', healthRatio, underruns === 0 ? 'Clean' : `${underruns} underrun${underruns === 1 ? '' : 's'}`, healthMode);
  1769. const txState = String(engine.state || 'idle').toLowerCase();
  1770. const activeRatio = txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.08;
  1771. const activeMode = txState === 'running' ? 'good' : state.txBusy ? 'warn' : 'err';
  1772. const activeText = txState === 'running' ? 'Live' : state.txBusy ? 'Working' : 'Idle';
  1773. setMeter('meter-tx-fill', 'meter-tx-text', activeRatio, activeText, activeMode);
  1774. }
  1775. function toast(msg, type = 'info') {
  1776. const t = $('toast');
  1777. t.textContent = msg;
  1778. t.className = 'toast ' + type + ' show';
  1779. clearTimeout(t._timer);
  1780. t._timer = setTimeout(() => t.classList.remove('show'), 2600);
  1781. }
  1782. function log(message, type = '') {
  1783. const logEl = $('log');
  1784. const empty = logEl.querySelector('.empty-log');
  1785. if (empty) empty.remove();
  1786. const row = document.createElement('div');
  1787. row.className = 'entry ' + type;
  1788. row.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  1789. logEl.appendChild(row);
  1790. while (logEl.children.length > 250) logEl.removeChild(logEl.firstChild);
  1791. logEl.scrollTop = logEl.scrollHeight;
  1792. }
  1793. function bindPanels() {
  1794. document.querySelectorAll('[data-panel]').forEach((head) => {
  1795. head.addEventListener('click', () => {
  1796. head.classList.toggle('collapsed');
  1797. head.nextElementSibling.classList.toggle('collapsed');
  1798. });
  1799. });
  1800. }
  1801. function bindInputs() {
  1802. $('freq-slider').addEventListener('input', (e) => {
  1803. const value = Number(e.target.value);
  1804. setDirty('frequencyMHz', value);
  1805. });
  1806. $('freq-num').addEventListener('input', (e) => {
  1807. const value = Number(e.target.value);
  1808. if (!Number.isNaN(value)) setDirty('frequencyMHz', value);
  1809. else {
  1810. state.errors.frequencyMHz = 'Enter a valid number.';
  1811. render();
  1812. }
  1813. });
  1814. $('rds-ps').addEventListener('input', (e) => setDirty('ps', e.target.value.toUpperCase().slice(0, 8)));
  1815. $('rds-rt').addEventListener('input', (e) => setDirty('radioText', e.target.value.slice(0, 64)));
  1816. $('freq-apply').addEventListener('click', () => applySection('freq'));
  1817. $('rds-apply').addEventListener('click', () => applySection('rds'));
  1818. $('freq-reset').addEventListener('click', () => resetSection('freq'));
  1819. $('rds-reset').addEventListener('click', () => resetSection('rds'));
  1820. $('btn-start').addEventListener('click', () => txAction('start'));
  1821. $('btn-stop').addEventListener('click', () => txAction('stop'));
  1822. $('danger-stop').addEventListener('click', () => txAction('stop'));
  1823. $('btn-refresh').addEventListener('click', manualRefresh);
  1824. $('danger-refresh').addEventListener('click', manualRefresh);
  1825. $('danger-reset-fault').addEventListener('click', () => resetFaultAction());
  1826. document.querySelectorAll('.toggle[data-toggle]').forEach((toggle) => {
  1827. const key = toggle.dataset.toggle;
  1828. const handler = () => setToggle(key, !serverValue(key));
  1829. toggle.addEventListener('click', handler);
  1830. toggle.addEventListener('keydown', (e) => {
  1831. if (e.key === 'Enter' || e.key === ' ') {
  1832. e.preventDefault();
  1833. handler();
  1834. }
  1835. });
  1836. });
  1837. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  1838. btn.addEventListener('click', () => {
  1839. const value = Number(btn.dataset.freqPreset);
  1840. setDirty('frequencyMHz', value);
  1841. toast(`Frequency preset ${value.toFixed(1)} MHz loaded`, 'info');
  1842. });
  1843. });
  1844. document.querySelectorAll('[data-rds-ps]').forEach((btn) => {
  1845. btn.addEventListener('click', () => {
  1846. setDirty('ps', btn.dataset.rdsPs || '');
  1847. setDirty('radioText', btn.dataset.rdsRt || '');
  1848. toast('RDS preset loaded', 'info');
  1849. });
  1850. });
  1851. $('btn-clear-log').addEventListener('click', () => {
  1852. $('log').innerHTML = '<div class="empty-log">No events yet.</div>';
  1853. toast('Log cleared', 'info');
  1854. });
  1855. }
  1856. async function manualRefresh() {
  1857. beginRequest();
  1858. try {
  1859. await Promise.allSettled([
  1860. loadConfig({ silent: true }),
  1861. loadRuntime({ silent: true }),
  1862. ]);
  1863. toast('Refreshed', 'info');
  1864. log('Manual refresh completed', 'info');
  1865. } finally {
  1866. endRequest();
  1867. }
  1868. }
  1869. function startPollers() {
  1870. if (state.pollersStarted) return;
  1871. state.pollersStarted = true;
  1872. setInterval(() => { loadRuntime({ silent: true }); }, runtimePollMs);
  1873. setInterval(() => { loadConfig({ silent: true }); }, configPollMs);
  1874. }
  1875. function bindResponsiveBehavior() {
  1876. const listener = () => {
  1877. if (!mobileMq.matches) {
  1878. state.mobilePanelsApplied = false;
  1879. return;
  1880. }
  1881. applyMobilePanelDefaults();
  1882. };
  1883. if (mobileMq.addEventListener) mobileMq.addEventListener('change', listener);
  1884. else mobileMq.addListener(listener);
  1885. }
  1886. function isTypingContext(target) {
  1887. if (!target) return false;
  1888. const tag = (target.tagName || '').toLowerCase();
  1889. return tag === 'input' || tag === 'textarea' || target.isContentEditable;
  1890. }
  1891. function bindKeyboardShortcuts() {
  1892. window.addEventListener('keydown', (e) => {
  1893. if (isTypingContext(e.target)) {
  1894. if (e.key !== 'Enter') return;
  1895. if (state.dirty.has('frequencyMHz')) {
  1896. e.preventDefault();
  1897. applySection('freq');
  1898. } else if (isDirtySection('rds')) {
  1899. e.preventDefault();
  1900. applySection('rds');
  1901. }
  1902. return;
  1903. }
  1904. if (e.key === 't' && !e.shiftKey) {
  1905. e.preventDefault();
  1906. txAction('start');
  1907. return;
  1908. }
  1909. if ((e.key === 'T') || (e.key === 't' && e.shiftKey)) {
  1910. e.preventDefault();
  1911. txAction('stop');
  1912. return;
  1913. }
  1914. if (e.key.toLowerCase() === 'r') {
  1915. e.preventDefault();
  1916. manualRefresh();
  1917. return;
  1918. }
  1919. if (e.key === '[') {
  1920. e.preventDefault();
  1921. cycleFreqPreset(-1);
  1922. return;
  1923. }
  1924. if (e.key === ']') {
  1925. e.preventDefault();
  1926. cycleFreqPreset(1);
  1927. return;
  1928. }
  1929. if (e.key === 'Enter') {
  1930. e.preventDefault();
  1931. if (state.dirty.has('frequencyMHz')) applySection('freq');
  1932. else if (isDirtySection('rds')) applySection('rds');
  1933. }
  1934. });
  1935. }
  1936. async function init() {
  1937. bindPanels();
  1938. bindInputs();
  1939. bindResponsiveBehavior();
  1940. bindKeyboardShortcuts();
  1941. render();
  1942. log('fm-rds-tx control UI booting', 'info');
  1943. await Promise.allSettled([
  1944. loadConfig({ silent: false }),
  1945. loadRuntime({ silent: true }),
  1946. ]);
  1947. render();
  1948. startPollers();
  1949. log('Polling active: runtime 1s, config 8s', 'ok');
  1950. log('Keyboard shortcuts armed', 'info');
  1951. }
  1952. init();
  1953. </script>
  1954. </body>
  1955. </html>