Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

2670 wiersze
84KB

  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 10px rgba(46, 125, 50, 0.45);
  124. }
  125. .led.on-red {
  126. background: var(--accent);
  127. box-shadow: 0 0 10px rgba(180, 35, 24, 0.45);
  128. }
  129. .led.on-amber {
  130. background: var(--amber);
  131. box-shadow: 0 0 10px rgba(178, 106, 0, 0.45);
  132. }
  133. .led.on-blue {
  134. background: var(--blue);
  135. box-shadow: 0 0 10px rgba(41, 98, 179, 0.45);
  136. }
  137. .status-text {
  138. font-size: 10px;
  139. color: var(--text-dim);
  140. text-transform: uppercase;
  141. letter-spacing: 1.2px;
  142. }
  143. .tab-bar {
  144. display: flex;
  145. gap: 10px;
  146. border-bottom: 1px solid var(--border);
  147. padding-bottom: 10px;
  148. margin: 0 -24px 18px;
  149. flex-wrap: wrap;
  150. }
  151. .tab-btn {
  152. border: 1px solid var(--border);
  153. border-radius: var(--radius);
  154. background: transparent;
  155. color: var(--text-dim);
  156. font-size: 12px;
  157. font-weight: 600;
  158. padding: 8px 18px;
  159. cursor: pointer;
  160. transition: all .16s ease;
  161. }
  162. .tab-btn.active {
  163. border-color: var(--accent);
  164. background: var(--surface);
  165. color: var(--accent);
  166. }
  167. .tab-btn:focus-visible {
  168. outline: 2px solid var(--accent);
  169. outline-offset: 2px;
  170. }
  171. .tab-panels {
  172. display: flex;
  173. flex-direction: column;
  174. gap: 14px;
  175. }
  176. .tab-panel {
  177. display: none;
  178. }
  179. .tab-panel.active {
  180. display: block;
  181. }
  182. .tab-columns {
  183. display: grid;
  184. gap: 16px;
  185. }
  186. .tab-columns.two {
  187. grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr);
  188. }
  189. .tab-columns.one {
  190. grid-template-columns: 1fr;
  191. }
  192. .stack { display: flex; flex-direction: column; gap: 12px; }
  193. .card {
  194. background: var(--surface);
  195. border: 1px solid var(--border);
  196. border-radius: var(--radius);
  197. box-shadow: var(--shadow);
  198. }
  199. .hero {
  200. padding: 16px;
  201. }
  202. @keyframes blinkSoft {
  203. 0%, 100% { opacity: 1; }
  204. 50% { opacity: .55; }
  205. }
  206. .tx-bar {
  207. position: relative;
  208. z-index: 1;
  209. display: grid;
  210. grid-template-columns: minmax(180px, 250px) 1fr auto;
  211. gap: 14px;
  212. align-items: center;
  213. }
  214. .freq-display-wrap {
  215. display: flex;
  216. flex-direction: column;
  217. gap: 6px;
  218. }
  219. .freq-display-label {
  220. font-size: 10px;
  221. text-transform: uppercase;
  222. letter-spacing: 1.4px;
  223. color: var(--text-dim);
  224. }
  225. .freq-display {
  226. font-family: var(--display);
  227. font-size: 38px;
  228. color: var(--text);
  229. letter-spacing: 0.6px;
  230. line-height: 1;
  231. font-weight: 600;
  232. }
  233. .freq-display .unit {
  234. font-family: var(--mono);
  235. font-size: 14px;
  236. color: var(--text-dim);
  237. margin-left: 5px;
  238. }
  239. .freq-note {
  240. display: flex;
  241. gap: 12px;
  242. margin-top: 6px;
  243. font-size: 11px;
  244. color: var(--text-muted);
  245. text-transform: uppercase;
  246. letter-spacing: 1px;
  247. }
  248. .freq-note-item {
  249. display: inline-flex;
  250. align-items: center;
  251. gap: 4px;
  252. }
  253. .freq-note.mismatch .freq-note-item {
  254. color: var(--amber);
  255. }
  256. .tx-actions {
  257. display: flex;
  258. flex-wrap: wrap;
  259. gap: 10px;
  260. }
  261. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn {
  262. min-height: 40px;
  263. padding: 0 18px;
  264. border-radius: var(--radius);
  265. border: 1px solid var(--border);
  266. background: var(--surface2);
  267. color: var(--text);
  268. cursor: pointer;
  269. font-size: 12px;
  270. font-weight: 700;
  271. text-transform: uppercase;
  272. letter-spacing: 1px;
  273. transition: all .16s ease;
  274. }
  275. .tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover {
  276. transform: translateY(-1px);
  277. border-color: var(--border-strong);
  278. }
  279. .tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled {
  280. opacity: .45;
  281. cursor: not-allowed;
  282. transform: none;
  283. }
  284. .tx-btn.start { border-color: var(--green); color: var(--green); }
  285. .tx-btn.start:hover:not(:disabled) { background: rgba(48,209,88,.1); }
  286. .tx-btn.stop { border-color: var(--accent); color: var(--accent); }
  287. .tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); }
  288. .ghost-btn { color: var(--text-dim); }
  289. .danger-btn {
  290. border-color: rgba(255,59,48,.45);
  291. color: var(--accent);
  292. background: rgba(255,59,48,.04);
  293. }
  294. .danger-btn:hover:not(:disabled) {
  295. background: rgba(255,59,48,.12);
  296. }
  297. .tx-state-wrap {
  298. display: flex;
  299. flex-direction: column;
  300. align-items: flex-end;
  301. gap: 6px;
  302. }
  303. .tx-state {
  304. font-size: 11px;
  305. text-transform: uppercase;
  306. letter-spacing: 2px;
  307. color: var(--text-dim);
  308. }
  309. .tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; }
  310. .tx-state.idle, .tx-state.stopped { color: var(--text-dim); }
  311. .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; }
  312. .tx-state.error { color: var(--accent); }
  313. .status-hint {
  314. font-size: 10px;
  315. color: var(--text-muted);
  316. text-align: right;
  317. }
  318. .quick-grid {
  319. position: relative;
  320. z-index: 1;
  321. display: grid;
  322. grid-template-columns: repeat(5, minmax(0, 1fr));
  323. gap: 10px;
  324. margin-top: 16px;
  325. }
  326. .quick-item {
  327. padding: 12px;
  328. border: 1px solid var(--border);
  329. border-radius: var(--radius);
  330. background: var(--bg-2);
  331. }
  332. .quick-item .label {
  333. font-size: 9px;
  334. text-transform: uppercase;
  335. letter-spacing: 1.4px;
  336. color: var(--text-dim);
  337. margin-bottom: 6px;
  338. }
  339. .quick-item .value {
  340. font-size: 18px;
  341. font-weight: 700;
  342. color: var(--text);
  343. }
  344. .quick-item .value.warn { color: var(--amber); }
  345. .quick-item .value.err { color: var(--accent); }
  346. .quick-item .value.good { color: var(--green); }
  347. .signal-grid {
  348. position: relative;
  349. z-index: 1;
  350. display: grid;
  351. grid-template-columns: repeat(3, minmax(0, 1fr));
  352. gap: 10px;
  353. margin-top: 12px;
  354. }
  355. .signal-card {
  356. padding: 12px;
  357. border: 1px solid var(--border);
  358. border-radius: var(--radius);
  359. background: var(--bg-2);
  360. }
  361. .signal-head {
  362. display: flex;
  363. align-items: center;
  364. justify-content: space-between;
  365. gap: 10px;
  366. margin-bottom: 8px;
  367. }
  368. .signal-title {
  369. font-size: 10px;
  370. color: var(--text-dim);
  371. text-transform: uppercase;
  372. letter-spacing: 1.2px;
  373. }
  374. .signal-value {
  375. font-size: 11px;
  376. color: var(--text);
  377. font-weight: 700;
  378. }
  379. .meter {
  380. width: 100%;
  381. height: 10px;
  382. border-radius: 999px;
  383. background: #171821;
  384. border: 1px solid var(--border);
  385. overflow: hidden;
  386. }
  387. .meter-fill {
  388. height: 100%;
  389. width: 0%;
  390. transition: width .25s ease, background-color .25s ease;
  391. background: linear-gradient(90deg, var(--green), #5cff90);
  392. }
  393. .meter-fill.warn {
  394. background: linear-gradient(90deg, var(--amber), #ffc45b);
  395. }
  396. .meter-fill.err {
  397. background: linear-gradient(90deg, var(--accent), #ff6b63);
  398. }
  399. .spark {
  400. width: 100%;
  401. height: 34px;
  402. margin-top: 10px;
  403. border-radius: 6px;
  404. background: rgba(255,255,255,0.01);
  405. border: 1px solid rgba(255,255,255,0.03);
  406. }
  407. .spark path.line {
  408. fill: none;
  409. stroke-width: 2;
  410. stroke-linecap: round;
  411. stroke-linejoin: round;
  412. }
  413. .spark path.area {
  414. opacity: .14;
  415. }
  416. .spark.good path.line { stroke: var(--green); }
  417. .spark.good path.area { fill: var(--green); }
  418. .spark.warn path.line { stroke: var(--amber); }
  419. .spark.warn path.area { fill: var(--amber); }
  420. .spark.err path.line { stroke: var(--accent); }
  421. .spark.err path.area { fill: var(--accent); }
  422. .panel {
  423. overflow: hidden;
  424. }
  425. .panel-head {
  426. display: flex;
  427. align-items: center;
  428. gap: 8px;
  429. padding: 12px 14px;
  430. border-bottom: 1px solid var(--border);
  431. background: var(--surface2);
  432. cursor: pointer;
  433. user-select: none;
  434. }
  435. .panel-head h2 {
  436. font-size: 11px;
  437. font-weight: 700;
  438. text-transform: uppercase;
  439. letter-spacing: 1.6px;
  440. color: var(--text-dim);
  441. }
  442. .panel-head .meta {
  443. margin-left: auto;
  444. margin-right: 8px;
  445. font-size: 10px;
  446. color: var(--text-muted);
  447. text-transform: uppercase;
  448. letter-spacing: 1px;
  449. }
  450. .panel-head .chevron {
  451. color: var(--text-dim);
  452. transition: transform .2s ease;
  453. font-size: 10px;
  454. }
  455. .panel-head.collapsed .chevron { transform: rotate(-90deg); }
  456. .panel-body { padding: 14px; }
  457. .panel-body.collapsed { display: none; }
  458. .section-note {
  459. font-size: 11px;
  460. color: var(--text-muted);
  461. margin-bottom: 12px;
  462. }
  463. .shortcuts-grid {
  464. display: grid;
  465. grid-template-columns: repeat(2, minmax(0, 1fr));
  466. gap: 8px 12px;
  467. }
  468. .shortcut-line {
  469. display: flex;
  470. align-items: center;
  471. justify-content: space-between;
  472. gap: 12px;
  473. padding: 7px 0;
  474. border-bottom: 1px solid #1a1a22;
  475. }
  476. .shortcut-line:last-child { border-bottom: none; }
  477. .shortcut-line .name { font-size: 11px; color: var(--text-dim); }
  478. .shortcut-line .keys {
  479. display: inline-flex;
  480. gap: 6px;
  481. flex-wrap: wrap;
  482. }
  483. .kbd {
  484. min-width: 28px;
  485. padding: 3px 7px;
  486. border: 1px solid var(--border);
  487. border-bottom-width: 2px;
  488. border-radius: 6px;
  489. background: var(--bg-2);
  490. font-size: 10px;
  491. color: var(--text);
  492. text-align: center;
  493. }
  494. .preset-row {
  495. display: flex;
  496. flex-wrap: wrap;
  497. gap: 8px;
  498. margin-bottom: 12px;
  499. }
  500. .preset-btn {
  501. min-height: 34px;
  502. padding: 0 12px;
  503. font-size: 11px;
  504. letter-spacing: .8px;
  505. color: var(--text-dim);
  506. }
  507. .preset-btn.active {
  508. border-color: var(--blue);
  509. color: var(--blue);
  510. background: rgba(10,132,255,.08);
  511. }
  512. .preset-btn.rds {
  513. text-transform: none;
  514. font-weight: 600;
  515. }
  516. .ctrl-row {
  517. display: flex;
  518. align-items: center;
  519. gap: 12px;
  520. padding: 10px 0;
  521. border-bottom: 1px solid #1a1a22;
  522. }
  523. .ctrl-row:last-child { border-bottom: none; }
  524. .ctrl-label-wrap {
  525. min-width: 130px;
  526. display: flex;
  527. flex-direction: column;
  528. gap: 2px;
  529. }
  530. .ctrl-label {
  531. font-size: 11px;
  532. color: var(--text-dim);
  533. text-transform: uppercase;
  534. letter-spacing: .8px;
  535. }
  536. .ctrl-sub {
  537. font-size: 10px;
  538. color: var(--text-muted);
  539. }
  540. .ctrl-input {
  541. flex: 1;
  542. display: flex;
  543. align-items: center;
  544. gap: 10px;
  545. }
  546. input[type="range"] {
  547. -webkit-appearance: none;
  548. appearance: none;
  549. flex: 1;
  550. height: 6px;
  551. background: linear-gradient(90deg, var(--border), var(--surface3));
  552. border-radius: 999px;
  553. outline: none;
  554. }
  555. input[type="range"]::-webkit-slider-thumb {
  556. -webkit-appearance: none;
  557. width: 16px;
  558. height: 16px;
  559. border-radius: 50%;
  560. background: var(--text);
  561. border: 2px solid var(--bg);
  562. cursor: pointer;
  563. transition: background .15s ease, transform .15s ease;
  564. }
  565. input[type="range"]::-webkit-slider-thumb:hover {
  566. background: var(--accent);
  567. transform: scale(1.06);
  568. }
  569. input[type="number"], input[type="text"] {
  570. background: var(--bg);
  571. border: 1px solid var(--border);
  572. border-radius: 6px;
  573. color: var(--text);
  574. padding: 8px 10px;
  575. outline: none;
  576. transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
  577. }
  578. input[type="number"] {
  579. width: 92px;
  580. text-align: right;
  581. }
  582. input[type="text"] { width: 100%; }
  583. input:focus {
  584. border-color: var(--accent);
  585. box-shadow: 0 0 0 3px rgba(255,59,48,.12);
  586. }
  587. input.input-dirty {
  588. border-color: var(--amber);
  589. box-shadow: 0 0 0 3px rgba(255,159,10,.08);
  590. }
  591. input.input-error {
  592. border-color: var(--accent);
  593. box-shadow: 0 0 0 3px rgba(255,59,48,.14);
  594. background: rgba(255,59,48,.04);
  595. }
  596. .val-display {
  597. min-width: 64px;
  598. text-align: right;
  599. font-size: 12px;
  600. font-weight: 700;
  601. color: var(--text);
  602. }
  603. .unit-label {
  604. font-size: 11px;
  605. color: var(--text-dim);
  606. min-width: 44px;
  607. }
  608. .field-error {
  609. display: none;
  610. margin-top: 8px;
  611. font-size: 11px;
  612. color: var(--accent);
  613. }
  614. .field-error.show { display: block; }
  615. .toggle-row {
  616. display: flex;
  617. align-items: center;
  618. justify-content: space-between;
  619. gap: 14px;
  620. padding: 12px 0;
  621. border-bottom: 1px solid #1a1a22;
  622. }
  623. .toggle-row:last-child { border-bottom: none; }
  624. .toggle-copy {
  625. display: flex;
  626. flex-direction: column;
  627. gap: 3px;
  628. }
  629. .toggle-copy .title {
  630. font-size: 12px;
  631. color: var(--text);
  632. font-weight: 700;
  633. }
  634. .toggle-copy .sub {
  635. font-size: 10px;
  636. color: var(--text-muted);
  637. }
  638. .toggle-ctl {
  639. display: flex;
  640. align-items: center;
  641. gap: 10px;
  642. }
  643. .toggle {
  644. position: relative;
  645. width: 42px;
  646. height: 24px;
  647. background: var(--border);
  648. border-radius: 999px;
  649. cursor: pointer;
  650. transition: all .2s ease;
  651. flex-shrink: 0;
  652. }
  653. .toggle::after {
  654. content: '';
  655. position: absolute;
  656. top: 3px;
  657. left: 3px;
  658. width: 18px;
  659. height: 18px;
  660. background: var(--text);
  661. border-radius: 50%;
  662. transition: transform .2s ease;
  663. }
  664. .toggle.on { background: var(--green); }
  665. .toggle.on::after { transform: translateX(18px); }
  666. .toggle.busy { opacity: .55; pointer-events: none; }
  667. .toggle-state {
  668. min-width: 52px;
  669. text-align: right;
  670. font-size: 11px;
  671. color: var(--text-dim);
  672. text-transform: uppercase;
  673. letter-spacing: 1px;
  674. }
  675. .rds-grid {
  676. display: grid;
  677. gap: 12px;
  678. }
  679. .rds-field {
  680. display: flex;
  681. flex-direction: column;
  682. gap: 6px;
  683. }
  684. .rds-input {
  685. width: 100%;
  686. background: var(--bg);
  687. border: 1px solid var(--border);
  688. border-radius: 6px;
  689. color: var(--green);
  690. font-family: var(--mono);
  691. font-size: 15px;
  692. font-weight: 700;
  693. padding: 10px 12px;
  694. outline: none;
  695. letter-spacing: 2px;
  696. text-transform: uppercase;
  697. }
  698. .rds-input.rt {
  699. color: var(--text);
  700. text-transform: none;
  701. letter-spacing: .5px;
  702. font-size: 12px;
  703. font-weight: 500;
  704. }
  705. .rds-charcount {
  706. font-size: 10px;
  707. color: var(--text-dim);
  708. text-align: right;
  709. }
  710. .actions-row {
  711. display: flex;
  712. gap: 10px;
  713. flex-wrap: wrap;
  714. margin-top: 14px;
  715. }
  716. .apply-btn {
  717. background: var(--accent);
  718. border-color: transparent;
  719. color: #fff;
  720. }
  721. .apply-btn.secondary {
  722. background: var(--surface2);
  723. color: var(--text-dim);
  724. border-color: var(--border);
  725. }
  726. .apply-btn.ok { background: var(--green); color: var(--bg); }
  727. .sidebar-card {
  728. padding: 14px;
  729. }
  730. .sidebar-section + .sidebar-section {
  731. margin-top: 14px;
  732. padding-top: 14px;
  733. border-top: 1px solid var(--border);
  734. }
  735. .sidebar-title {
  736. font-size: 11px;
  737. text-transform: uppercase;
  738. letter-spacing: 1.4px;
  739. color: var(--text-dim);
  740. margin-bottom: 10px;
  741. }
  742. .kv {
  743. display: grid;
  744. grid-template-columns: auto 1fr;
  745. gap: 8px 12px;
  746. align-items: start;
  747. }
  748. .kv .k {
  749. font-size: 10px;
  750. color: var(--text-muted);
  751. text-transform: uppercase;
  752. letter-spacing: 1px;
  753. }
  754. .kv .v {
  755. font-size: 12px;
  756. color: var(--text);
  757. word-break: break-word;
  758. }
  759. .health-line {
  760. display: flex;
  761. align-items: center;
  762. justify-content: space-between;
  763. gap: 10px;
  764. padding: 8px 0;
  765. border-bottom: 1px solid #1a1a22;
  766. }
  767. .health-line:last-child { border-bottom: none; }
  768. .health-line .name {
  769. font-size: 11px;
  770. color: var(--text-dim);
  771. }
  772. .health-line .val {
  773. font-size: 11px;
  774. color: var(--text);
  775. text-align: right;
  776. }
  777. .health-line .val.good { color: var(--green); }
  778. .health-line .val.warn { color: var(--amber); }
  779. .health-line .val.err { color: var(--accent); }
  780. .health-trend {
  781. margin-top: 10px;
  782. }
  783. .health-trend-label {
  784. font-size: 10px;
  785. text-transform: uppercase;
  786. letter-spacing: 1px;
  787. color: var(--text-muted);
  788. margin-bottom: 6px;
  789. }
  790. .fault-history {
  791. margin-top: 12px;
  792. padding: 10px;
  793. border: 1px solid var(--border);
  794. border-radius: 6px;
  795. background: var(--surface1);
  796. font-size: 11px;
  797. max-height: 180px;
  798. overflow-y: auto;
  799. line-height: 1.3;
  800. }
  801. .fault-history-entry {
  802. display: flex;
  803. justify-content: space-between;
  804. gap: 10px;
  805. padding: 4px 0;
  806. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  807. }
  808. .fault-history-entry:last-child {
  809. border-bottom: none;
  810. }
  811. .fault-history-entry .fault-history-time {
  812. color: var(--text-dim);
  813. }
  814. .fault-history-entry.ok { color: var(--green); }
  815. .fault-history-entry.warn { color: var(--amber); }
  816. .fault-history-entry.err { color: var(--accent); }
  817. .fault-history-desc {
  818. font-size: 10px;
  819. flex: 1;
  820. text-transform: uppercase;
  821. letter-spacing: 0.5px;
  822. }
  823. .fault-history-empty {
  824. padding: 6px 0;
  825. color: var(--text-muted);
  826. font-size: 11px;
  827. }
  828. .transition-history {
  829. margin-top: 12px;
  830. padding: 10px;
  831. border: 1px solid var(--border);
  832. border-radius: 6px;
  833. background: var(--surface);
  834. font-size: 11px;
  835. max-height: 180px;
  836. overflow-y: auto;
  837. line-height: 1.3;
  838. }
  839. .transition-history-entry {
  840. display: flex;
  841. justify-content: space-between;
  842. gap: 10px;
  843. padding: 4px 0;
  844. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  845. }
  846. .transition-history-entry:last-child {
  847. border-bottom: none;
  848. }
  849. .transition-history-entry .transition-history-time {
  850. color: var(--text-dim);
  851. }
  852. .transition-history-entry.good { color: var(--green); }
  853. .transition-history-entry.warn { color: var(--amber); }
  854. .transition-history-entry.err { color: var(--accent); }
  855. .transition-history-entry.info { color: var(--text); }
  856. .transition-history-desc {
  857. font-size: 10px;
  858. flex: 1;
  859. text-transform: uppercase;
  860. letter-spacing: 0.5px;
  861. }
  862. .transition-history-empty {
  863. padding: 6px 0;
  864. color: var(--text-muted);
  865. font-size: 11px;
  866. }
  867. .section-note.reset-hint {
  868. font-size: 11px;
  869. color: var(--text-dim);
  870. margin-top: 10px;
  871. }
  872. .log {
  873. background: var(--bg);
  874. border: 1px solid var(--border);
  875. border-radius: 6px;
  876. padding: 10px;
  877. font-size: 10px;
  878. color: var(--text-dim);
  879. max-height: 220px;
  880. overflow-y: auto;
  881. white-space: pre-wrap;
  882. word-break: break-word;
  883. }
  884. .log .entry { padding: 3px 0; }
  885. .log .entry.err { color: var(--accent); }
  886. .log .entry.ok { color: var(--green); }
  887. .log .entry.warn { color: var(--amber); }
  888. .log .entry.info { color: var(--blue); }
  889. .empty-log {
  890. color: var(--text-muted);
  891. }
  892. .toast {
  893. position: fixed;
  894. right: 16px;
  895. bottom: 16px;
  896. max-width: min(420px, calc(100vw - 24px));
  897. padding: 12px 15px;
  898. border-radius: var(--radius);
  899. font-size: 12px;
  900. font-weight: 700;
  901. z-index: 2000;
  902. transform: translateY(60px);
  903. opacity: 0;
  904. transition: all .25s ease;
  905. box-shadow: var(--shadow);
  906. }
  907. .toast.show { transform: translateY(0); opacity: 1; }
  908. .toast.ok { background: var(--green); color: var(--bg); }
  909. .toast.err { background: var(--accent); color: #fff; }
  910. .toast.info { background: var(--blue); color: #fff; }
  911. .toast.warn { background: var(--amber); color: #141414; }
  912. @media (max-width: 980px) {
  913. .tab-columns.two {
  914. grid-template-columns: 1fr;
  915. }
  916. .tab-bar {
  917. margin: 0 -12px 18px;
  918. }
  919. .tx-bar {
  920. grid-template-columns: 1fr;
  921. align-items: stretch;
  922. }
  923. .tx-state-wrap {
  924. align-items: flex-start;
  925. }
  926. .status-hint { text-align: left; }
  927. .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  928. .signal-grid { grid-template-columns: 1fr; }
  929. }
  930. @media (max-width: 640px) {
  931. .tab-columns.two {
  932. grid-template-columns: 1fr;
  933. }
  934. .tab-bar {
  935. margin: 0 -12px 14px;
  936. }
  937. .app { padding: 12px; }
  938. .header { flex-direction: column; align-items: stretch; gap: 10px; }
  939. .header h1 { font-size: 22px; }
  940. .header-sub { gap: 6px; }
  941. .badge { width: 100%; justify-content: space-between; }
  942. .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
  943. .quick-item { padding: 10px; }
  944. .quick-item .value { font-size: 16px; }
  945. .ctrl-row { flex-direction: column; align-items: stretch; }
  946. .ctrl-label-wrap { min-width: auto; }
  947. .ctrl-input { flex-wrap: wrap; }
  948. input[type="number"] { width: 100%; text-align: left; }
  949. .actions-row, .tx-actions { flex-direction: column; }
  950. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; }
  951. .panel-head { padding: 11px 12px; }
  952. .panel-body, .sidebar-card { padding: 12px; }
  953. .freq-display { font-size: 31px; }
  954. .preset-row { flex-direction: column; }
  955. .shortcuts-grid { grid-template-columns: 1fr; }
  956. }
  957. </style>
  958. </head>
  959. <body>
  960. <div class="app">
  961. <div class="header">
  962. <div class="header-main">
  963. <h1>FM-RDS-TX</h1>
  964. <div class="header-sub">
  965. <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div>
  966. <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div>
  967. <div class="badge"><span>Live Config</span><strong id="badge-live">--</strong></div>
  968. </div>
  969. </div>
  970. <div class="header-status">
  971. <div class="led" id="led-conn"></div>
  972. <div class="status-text" id="conn-label">connecting</div>
  973. </div>
  974. </div>
  975. <div class="tab-bar">
  976. <button class="tab-btn active" data-tab="overview" type="button">Overview</button>
  977. <button class="tab-btn" data-tab="control" type="button">Control</button>
  978. <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics</button>
  979. <button class="tab-btn" data-tab="activity" type="button">Activity</button>
  980. </div>
  981. <div class="tab-panels">
  982. <section class="tab-panel active" data-tab-panel="overview">
  983. <div class="tab-columns two">
  984. <div class="stack">
  985. <div class="card hero" id="hero-card">
  986. <div class="tx-bar">
  987. <div class="freq-display-wrap">
  988. <div class="freq-display-label">Carrier</div>
  989. <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
  990. <div class="freq-note" id="freq-note">
  991. <span class="freq-note-item" id="freq-applied">Applied: --</span>
  992. <span class="freq-note-item" id="freq-desired">Desired: --</span>
  993. </div>
  994. </div>
  995. <div class="tx-actions">
  996. <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
  997. <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
  998. <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
  999. </div>
  1000. <div class="tx-state-wrap">
  1001. <div class="tx-state idle" id="tx-state">IDLE</div>
  1002. <div class="status-hint" id="tx-hint">Awaiting runtime data</div>
  1003. </div>
  1004. </div>
  1005. <div class="quick-grid">
  1006. <div class="quick-item">
  1007. <div class="label">Chunks</div>
  1008. <div class="value" id="t-chunks">--</div>
  1009. </div>
  1010. <div class="quick-item">
  1011. <div class="label">Samples</div>
  1012. <div class="value" id="t-samples">--</div>
  1013. </div>
  1014. <div class="quick-item">
  1015. <div class="label">Underruns</div>
  1016. <div class="value" id="t-underruns">--</div>
  1017. </div>
  1018. <div class="quick-item">
  1019. <div class="label">Uptime</div>
  1020. <div class="value" id="t-uptime">--</div>
  1021. </div>
  1022. <div class="quick-item">
  1023. <div class="label">Rate</div>
  1024. <div class="value" id="t-rate">--</div>
  1025. </div>
  1026. </div>
  1027. <div class="signal-grid">
  1028. <div class="signal-card">
  1029. <div class="signal-head">
  1030. <div class="signal-title">Audio Buffer</div>
  1031. <div class="signal-value" id="meter-audio-text">--</div>
  1032. </div>
  1033. <div class="meter"><div class="meter-fill" id="meter-audio-fill"></div></div>
  1034. <svg class="spark good" id="spark-audio" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1035. </div>
  1036. <div class="signal-card">
  1037. <div class="signal-head">
  1038. <div class="signal-title">Stream Health</div>
  1039. <div class="signal-value" id="meter-stream-text">--</div>
  1040. </div>
  1041. <div class="meter"><div class="meter-fill" id="meter-stream-fill"></div></div>
  1042. <svg class="spark warn" id="spark-underruns" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1043. </div>
  1044. <div class="signal-card">
  1045. <div class="signal-head">
  1046. <div class="signal-title">TX Activity</div>
  1047. <div class="signal-value" id="meter-tx-text">--</div>
  1048. </div>
  1049. <div class="meter"><div class="meter-fill" id="meter-tx-fill"></div></div>
  1050. <svg class="spark good" id="spark-tx" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1051. </div>
  1052. </div>
  1053. </div>
  1054. </div>
  1055. <div class="stack">
  1056. <div class="card sidebar-card">
  1057. <div class="sidebar-section">
  1058. <div class="sidebar-title">System Snapshot</div>
  1059. <div class="kv">
  1060. <div class="k">Backend</div><div class="v" id="info-backend">--</div>
  1061. <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
  1062. <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
  1063. <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div>
  1064. <div class="k">Live Config</div><div class="v" id="info-live">--</div>
  1065. </div>
  1066. </div>
  1067. </div>
  1068. </div>
  1069. </div>
  1070. </section>
  1071. <section class="tab-panel" data-tab-panel="control">
  1072. <div class="tab-columns two">
  1073. <div class="stack">
  1074. <div class="card panel" data-panel-key="frequency">
  1075. <div class="panel-head" data-panel>
  1076. <div class="led on-green" style="width:6px;height:6px"></div>
  1077. <h2>Frequency</h2>
  1078. <div class="meta" id="freq-meta">Live-tunable</div>
  1079. <span class="chevron">▼</span>
  1080. </div>
  1081. <div class="panel-body">
  1082. <div class="section-note">Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.</div>
  1083. <div class="preset-row" id="freq-presets">
  1084. <button class="preset-btn" type="button" data-freq-preset="87.6">87.6 MHz</button>
  1085. <button class="preset-btn" type="button" data-freq-preset="94.5">94.5 MHz</button>
  1086. <button class="preset-btn" type="button" data-freq-preset="99.5">99.5 MHz</button>
  1087. <button class="preset-btn" type="button" data-freq-preset="100.0">100.0 MHz</button>
  1088. <button class="preset-btn" type="button" data-freq-preset="107.9">107.9 MHz</button>
  1089. </div>
  1090. <div class="ctrl-row">
  1091. <div class="ctrl-label-wrap">
  1092. <span class="ctrl-label">TX Freq</span>
  1093. <span class="ctrl-sub">Valid range 65–110 MHz</span>
  1094. </div>
  1095. <div class="ctrl-input">
  1096. <input type="range" min="65" max="110" step="0.1" id="freq-slider">
  1097. <input type="number" min="65" max="110" step="0.1" id="freq-num">
  1098. <span class="unit-label">MHz</span>
  1099. </div>
  1100. </div>
  1101. <div class="field-error" id="freq-error"></div>
  1102. <div class="actions-row">
  1103. <button class="apply-btn" id="freq-apply" type="button">Apply Frequency</button>
  1104. <button class="apply-btn secondary" id="freq-reset" type="button">Reset</button>
  1105. </div>
  1106. </div>
  1107. </div>
  1108. <div class="card panel" data-panel-key="switches">
  1109. <div class="panel-head" data-panel>
  1110. <div class="led on-green" style="width:6px;height:6px"></div>
  1111. <h2>Switches</h2>
  1112. <div class="meta">Live</div>
  1113. <span class="chevron">▼</span>
  1114. </div>
  1115. <div class="panel-body">
  1116. <div class="section-note">These switches apply immediately and show a busy state while the request is in flight.</div>
  1117. <div class="toggle-row">
  1118. <div class="toggle-copy">
  1119. <div class="title">Stereo</div>
  1120. <div class="sub">19 kHz pilot + 38 kHz DSB-SC</div>
  1121. </div>
  1122. <div class="toggle-ctl">
  1123. <div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1124. <div class="toggle-state" id="stereo-label">--</div>
  1125. </div>
  1126. </div>
  1127. <div class="toggle-row">
  1128. <div class="toggle-copy">
  1129. <div class="title">RDS</div>
  1130. <div class="sub">57 kHz subcarrier encoder</div>
  1131. </div>
  1132. <div class="toggle-ctl">
  1133. <div class="toggle" id="tog-rds" data-toggle="rdsEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1134. <div class="toggle-state" id="rds-label">--</div>
  1135. </div>
  1136. </div>
  1137. <div class="toggle-row">
  1138. <div class="toggle-copy">
  1139. <div class="title">Limiter</div>
  1140. <div class="sub">MPX peak protection</div>
  1141. </div>
  1142. <div class="toggle-ctl">
  1143. <div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1144. <div class="toggle-state" id="limiter-label">--</div>
  1145. </div>
  1146. </div>
  1147. </div>
  1148. </div>
  1149. <div class="card panel" data-panel-key="rds">
  1150. <div class="panel-head" data-panel>
  1151. <div class="led on-amber" style="width:6px;height:6px"></div>
  1152. <h2>RDS Text</h2>
  1153. <div class="meta" id="rds-meta">PS + RT</div>
  1154. <span class="chevron">▼</span>
  1155. </div>
  1156. <div class="panel-body">
  1157. <div class="section-note">Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.</div>
  1158. <div class="preset-row" id="rds-presets">
  1159. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  1160. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  1161. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  1162. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  1163. </div>
  1164. <div class="rds-grid">
  1165. <div class="rds-field">
  1166. <span class="ctrl-label">Program Service (PS)</span>
  1167. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  1168. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  1169. <div class="field-error" id="ps-error"></div>
  1170. </div>
  1171. <div class="rds-field">
  1172. <span class="ctrl-label">RadioText (RT)</span>
  1173. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  1174. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  1175. <div class="field-error" id="rt-error"></div>
  1176. </div>
  1177. </div>
  1178. <div class="actions-row">
  1179. <button class="apply-btn" id="rds-apply" type="button">Apply RDS Text</button>
  1180. <button class="apply-btn secondary" id="rds-reset" type="button">Reset</button>
  1181. </div>
  1182. </div>
  1183. </div>
  1184. </div>
  1185. <div class="stack">
  1186. <div class="card panel" data-panel-key="shortcuts">
  1187. <div class="panel-head" data-panel>
  1188. <h2>Shortcuts</h2>
  1189. <div class="meta">keyboard</div>
  1190. <span class="chevron">▼</span>
  1191. </div>
  1192. <div class="panel-body">
  1193. <div class="section-note">Fast control, as long as you're not typing in an input field.</div>
  1194. <div class="shortcuts-grid">
  1195. <div>
  1196. <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div>
  1197. <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>
  1198. <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div>
  1199. </div>
  1200. <div>
  1201. <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div>
  1202. <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div>
  1203. <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div>
  1204. </div>
  1205. </div>
  1206. </div>
  1207. </div>
  1208. <div class="card panel" data-panel-key="danger">
  1209. <div class="panel-head" data-panel>
  1210. <h2>Danger Zone</h2>
  1211. <div class="meta">tx control</div>
  1212. <span class="chevron">▼</span>
  1213. </div>
  1214. <div class="panel-body">
  1215. <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div>
  1216. <div class="actions-row" style="margin-top:0">
  1217. <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button>
  1218. <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button>
  1219. <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button>
  1220. </div>
  1221. <div class="section-note reset-hint" id="reset-hint">
  1222. Reset Fault moves the runtime back to DEGRADED while the queue settles before running again.
  1223. </div>
  1224. </div>
  1225. </div>
  1226. </div>
  1227. </div>
  1228. </section>
  1229. <section class="tab-panel" data-tab-panel="diagnostics">
  1230. <div class="tab-columns two">
  1231. <div class="stack">
  1232. <div class="card sidebar-card">
  1233. <div class="sidebar-section">
  1234. <div class="sidebar-title">Health</div>
  1235. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  1236. <div class="health-line"><div class="name">Runtime</div><div class="val" id="health-runtime">--</div></div>
  1237. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  1238. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  1239. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  1240. <div class="health-line"><div class="name">Transitions (D/M/F)</div><div class="val" id="health-transitions">--</div></div>
  1241. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  1242. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  1243. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  1244. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  1245. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  1246. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  1247. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  1248. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  1249. <div class="health-trend">
  1250. <div class="health-trend-label">High Watermark Trend</div>
  1251. <svg class="spark warn" id="spark-high-watermark" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1252. </div>
  1253. <div class="health-trend">
  1254. <div class="health-trend-label">Queue Fill Trend</div>
  1255. <svg class="spark good" id="spark-queue-fill" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1256. </div>
  1257. </div>
  1258. </div>
  1259. <div class="card sidebar-card">
  1260. <div class="sidebar-section">
  1261. <div class="sidebar-title">Control Audit</div>
  1262. <div class="section-note">Counts of 4xx rejects recorded by the control plane APIs.</div>
  1263. <div class="kv">
  1264. <div class="k">Rejects total</div><div class="v" id="audit-total">--</div>
  1265. <div class="k">405 Method Not Allowed</div><div class="v" id="audit-methodNotAllowed">--</div>
  1266. <div class="k">415 Unsupported Media Type</div><div class="v" id="audit-unsupportedMediaType">--</div>
  1267. <div class="k">413 Request Too Large</div><div class="v" id="audit-bodyTooLarge">--</div>
  1268. <div class="k">400 Unexpected Body</div><div class="v" id="audit-unexpectedBody">--</div>
  1269. </div>
  1270. </div>
  1271. </div>
  1272. </div>
  1273. <div class="stack">
  1274. <div class="card panel" data-panel-key="transition-history">
  1275. <div class="panel-head" data-panel>
  1276. <h2>Transition History</h2>
  1277. <div class="meta">recent state shifts</div>
  1278. <span class="chevron"></span>
  1279. </div>
  1280. <div class="panel-body">
  1281. <div class="section-note">Keeps runtime escalations visible without scrolling the activity log.</div>
  1282. <div class="transition-history" id="transition-history">
  1283. <div class="transition-history-empty">No transitions yet.</div>
  1284. </div>
  1285. </div>
  1286. </div>
  1287. <div class="card panel" data-panel-key="fault-history">
  1288. <div class="panel-head" data-panel>
  1289. <h2>Fault History</h2>
  1290. <div class="meta">recent faults</div>
  1291. <span class="chevron">▼</span>
  1292. </div>
  1293. <div class="panel-body">
  1294. <div class="section-note">Recent fault events for quick ops situational awareness.</div>
  1295. <div class="fault-history" id="fault-history">
  1296. <div class="fault-history-empty">No faults yet.</div>
  1297. </div>
  1298. </div>
  1299. </div>
  1300. </div>
  1301. </div>
  1302. </section>
  1303. <section class="tab-panel" data-tab-panel="activity">
  1304. <div class="tab-columns one">
  1305. <div class="stack">
  1306. <div class="card panel" data-panel-key="log">
  1307. <div class="panel-head" data-panel>
  1308. <h2>Activity Log</h2>
  1309. <div class="meta" id="log-meta">recent events</div>
  1310. <span class="chevron">▼</span>
  1311. </div>
  1312. <div class="panel-body">
  1313. <div class="actions-row" style="margin-top:0;margin-bottom:12px">
  1314. <button class="ghost-btn" id="btn-clear-log" type="button">Clear Log</button>
  1315. </div>
  1316. <div class="log" id="log"><div class="empty-log">No events yet.</div></div>
  1317. </div>
  1318. </div>
  1319. </div>
  1320. </div>
  1321. </section>
  1322. </div>
  1323. </div>
  1324. <div class="toast" id="toast"></div>
  1325. <script>
  1326. const $ = (id) => document.getElementById(id);
  1327. const runtimePollMs = 1000;
  1328. const configPollMs = 8000;
  1329. const mobileMq = window.matchMedia('(max-width: 640px)');
  1330. const freqPresetValues = [87.6, 94.5, 99.5, 100.0, 107.9];
  1331. const sparkHistoryLimit = 40;
  1332. const transitionHistoryLimit = 6;
  1333. const state = {
  1334. server: {
  1335. config: null,
  1336. runtime: null,
  1337. lastConfigAt: 0,
  1338. lastRuntimeAt: 0,
  1339. configOk: false,
  1340. runtimeOk: false,
  1341. },
  1342. lastRuntimeState: '',
  1343. draft: {
  1344. frequencyMHz: undefined,
  1345. ps: undefined,
  1346. radioText: undefined,
  1347. },
  1348. errors: {
  1349. frequencyMHz: '',
  1350. ps: '',
  1351. radioText: '',
  1352. },
  1353. dirty: new Set(),
  1354. pendingRequests: 0,
  1355. txBusy: false,
  1356. faultResetBusy: false,
  1357. toggleBusy: {},
  1358. pollersStarted: false,
  1359. mobilePanelsApplied: false,
  1360. charts: {
  1361. audio: [],
  1362. underruns: [],
  1363. tx: [],
  1364. highWatermark: [],
  1365. queueFill: [],
  1366. },
  1367. runtimeTransitions: [],
  1368. freqPresetIndex: 0,
  1369. };
  1370. const fields = {
  1371. frequencyMHz: { section: 'freq', equal: (a,b) => nearlyEqual(a,b,0.0001) },
  1372. ps: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1373. radioText: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1374. };
  1375. function nearlyEqual(a, b, eps = 1e-9) {
  1376. if (a == null && b == null) return true;
  1377. if (a == null || b == null) return false;
  1378. return Math.abs(Number(a) - Number(b)) <= eps;
  1379. }
  1380. function nowTs() { return Date.now(); }
  1381. function serverValue(key) {
  1382. const cfg = state.server.config;
  1383. if (!cfg) return undefined;
  1384. switch (key) {
  1385. case 'frequencyMHz': return cfg.fm?.frequencyMHz;
  1386. case 'ps': return cfg.rds?.ps ?? '';
  1387. case 'radioText': return cfg.rds?.radioText ?? '';
  1388. case 'stereoEnabled': return cfg.fm?.stereoEnabled;
  1389. case 'rdsEnabled': return cfg.rds?.enabled;
  1390. case 'limiterEnabled': return cfg.fm?.limiterEnabled;
  1391. default: return undefined;
  1392. }
  1393. }
  1394. function effectiveValue(key) {
  1395. return state.dirty.has(key) ? state.draft[key] : serverValue(key);
  1396. }
  1397. function validateField(key, value) {
  1398. switch (key) {
  1399. case 'frequencyMHz': {
  1400. if (value == null || Number.isNaN(Number(value))) return 'Enter a valid number.';
  1401. const num = Number(value);
  1402. if (num < 65 || num > 110) return 'Frequency must stay between 65 and 110 MHz.';
  1403. return '';
  1404. }
  1405. case 'ps': {
  1406. const text = String(value ?? '');
  1407. if (text.length > 8) return 'PS is limited to 8 characters.';
  1408. return '';
  1409. }
  1410. case 'radioText': {
  1411. const text = String(value ?? '');
  1412. if (text.length > 64) return 'RadioText is limited to 64 characters.';
  1413. return '';
  1414. }
  1415. default:
  1416. return '';
  1417. }
  1418. }
  1419. function sectionHasErrors(section) {
  1420. return Object.entries(fields).some(([key, meta]) => meta.section === section && state.errors[key]);
  1421. }
  1422. function setDirty(key, value) {
  1423. state.draft[key] = value;
  1424. state.errors[key] = validateField(key, value);
  1425. const current = serverValue(key);
  1426. const equal = !state.errors[key] && fields[key].equal(value, current);
  1427. if (equal) {
  1428. state.dirty.delete(key);
  1429. state.draft[key] = undefined;
  1430. } else {
  1431. state.dirty.add(key);
  1432. }
  1433. if (key === 'frequencyMHz' && typeof value === 'number') syncFreqPresetIndex(value);
  1434. render();
  1435. }
  1436. function clearDirty(keys) {
  1437. for (const key of keys) {
  1438. state.dirty.delete(key);
  1439. state.draft[key] = undefined;
  1440. state.errors[key] = '';
  1441. }
  1442. render();
  1443. }
  1444. function isDirtySection(section) {
  1445. for (const key of state.dirty) {
  1446. if (fields[key]?.section === section) return true;
  1447. }
  1448. return false;
  1449. }
  1450. function getSectionPatch(section) {
  1451. const patch = {};
  1452. for (const key of state.dirty) {
  1453. if (fields[key]?.section === section && !state.errors[key]) patch[key] = state.draft[key];
  1454. }
  1455. return patch;
  1456. }
  1457. async function api(path, opts) {
  1458. const response = await fetch(path, opts);
  1459. const text = await response.text();
  1460. if (!response.ok) {
  1461. throw new Error(text.trim() || `HTTP ${response.status}`);
  1462. }
  1463. if (!text) return {};
  1464. try {
  1465. return JSON.parse(text);
  1466. } catch {
  1467. return { ok: true, raw: text };
  1468. }
  1469. }
  1470. function setConnection(ok, mode) {
  1471. const led = $('led-conn');
  1472. const label = $('conn-label');
  1473. if (ok) {
  1474. led.className = 'led on-green';
  1475. label.textContent = mode || 'connected';
  1476. } else {
  1477. led.className = 'led on-red';
  1478. label.textContent = mode || 'offline';
  1479. }
  1480. }
  1481. async function loadConfig({ silent = false } = {}) {
  1482. try {
  1483. const cfg = await api('/config');
  1484. state.server.config = cfg;
  1485. state.server.configOk = true;
  1486. state.server.lastConfigAt = nowTs();
  1487. syncFreqPresetIndex(cfg.fm?.frequencyMHz);
  1488. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1489. render();
  1490. if (!silent) log('Config synced from server', 'info');
  1491. return cfg;
  1492. } catch (error) {
  1493. state.server.configOk = false;
  1494. if (!state.server.runtimeOk) setConnection(false, 'offline');
  1495. render();
  1496. if (!silent) log('Config load failed: ' + error.message, 'err');
  1497. throw error;
  1498. }
  1499. }
  1500. async function loadRuntime({ silent = true } = {}) {
  1501. try {
  1502. const runtime = await api('/runtime');
  1503. state.server.runtime = runtime;
  1504. state.server.runtimeOk = true;
  1505. state.server.lastRuntimeAt = nowTs();
  1506. const syncedTransitions = syncTransitionHistoryFromEngine(runtime.engine);
  1507. notifyRuntimeTransition(runtime.engine, !syncedTransitions);
  1508. pushHistory(runtime);
  1509. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1510. render();
  1511. return runtime;
  1512. } catch (error) {
  1513. state.server.runtimeOk = false;
  1514. if (!state.server.configOk) setConnection(false, 'offline');
  1515. render();
  1516. if (!silent) log('Runtime load failed: ' + error.message, 'err');
  1517. throw error;
  1518. }
  1519. }
  1520. function pushHistory(runtime) {
  1521. const engine = runtime.engine || {};
  1522. const driver = runtime.driver || {};
  1523. const audio = runtime.audioStream || {};
  1524. pushChart(state.charts.audio, typeof audio.buffered === 'number' ? audio.buffered : 0);
  1525. const highWatermarkDurationSeconds = Number(audio.highWatermarkDurationSeconds);
  1526. const normalizedHighWatermark = Number.isFinite(highWatermarkDurationSeconds) ? highWatermarkDurationSeconds : 0;
  1527. pushChart(state.charts.highWatermark, normalizedHighWatermark);
  1528. const queueFill = Number(engine.queue?.fillLevel ?? 0);
  1529. pushChart(state.charts.queueFill, Number.isFinite(queueFill) ? queueFill : 0);
  1530. pushChart(state.charts.underruns, Number(engine.underruns ?? driver.underruns ?? 0));
  1531. const txState = String(engine.state || 'idle').toLowerCase();
  1532. pushChart(state.charts.tx, txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.05);
  1533. }
  1534. function pushTransitionHistory(from, to, severity) {
  1535. if (!from || !to) return;
  1536. const entry = {
  1537. from: normalizeRuntimeState(from),
  1538. to: normalizeRuntimeState(to),
  1539. severity: severity || 'info',
  1540. time: nowTs(),
  1541. };
  1542. state.runtimeTransitions.unshift(entry);
  1543. if (state.runtimeTransitions.length > transitionHistoryLimit) {
  1544. state.runtimeTransitions.splice(transitionHistoryLimit);
  1545. }
  1546. updateTransitionHistory();
  1547. }
  1548. function transitionEntryTime(value) {
  1549. if (value == null) return nowTs();
  1550. if (typeof value === 'number') return value;
  1551. const parsed = Date.parse(String(value));
  1552. return Number.isNaN(parsed) ? nowTs() : parsed;
  1553. }
  1554. function syncTransitionHistoryFromEngine(engine) {
  1555. const entries = Array.isArray(engine?.transitionHistory) ? engine.transitionHistory : null;
  1556. if (!entries) return false;
  1557. const sliceStart = Math.max(0, entries.length - transitionHistoryLimit);
  1558. const trimmed = entries.slice(sliceStart);
  1559. const normalized = trimmed.map((entry) => ({
  1560. from: normalizeRuntimeState(entry?.from),
  1561. to: normalizeRuntimeState(entry?.to),
  1562. severity: String(entry?.severity || 'info').toLowerCase(),
  1563. time: transitionEntryTime(entry?.time),
  1564. }));
  1565. normalized.reverse();
  1566. state.runtimeTransitions = normalized;
  1567. updateTransitionHistory();
  1568. return true;
  1569. }
  1570. function transitionSeverityClass(severity) {
  1571. switch (String(severity || '').toLowerCase()) {
  1572. case 'err':
  1573. return 'err';
  1574. case 'warn':
  1575. return 'warn';
  1576. case 'ok':
  1577. case 'good':
  1578. return 'good';
  1579. default:
  1580. return 'info';
  1581. }
  1582. }
  1583. function updateTransitionHistory() {
  1584. const container = $('transition-history');
  1585. if (!container) return;
  1586. if (!state.runtimeTransitions.length) {
  1587. container.innerHTML = '<div class="transition-history-empty">No transitions yet.</div>';
  1588. return;
  1589. }
  1590. const rows = state.runtimeTransitions.map((entry) => {
  1591. const when = entry?.time ? new Date(entry.time) : null;
  1592. const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--';
  1593. const desc = `${entry.from.toUpperCase()} → ${entry.to.toUpperCase()}`;
  1594. const severityClass = transitionSeverityClass(entry.severity);
  1595. return `<div class="transition-history-entry ${severityClass}"><span class="transition-history-time">${timeLabel}</span><span class="transition-history-desc">${desc}</span></div>`;
  1596. });
  1597. container.innerHTML = rows.join('');
  1598. }
  1599. function pushChart(arr, value) {
  1600. arr.push(Number.isFinite(value) ? value : 0);
  1601. if (arr.length > sparkHistoryLimit) arr.splice(0, arr.length - sparkHistoryLimit);
  1602. }
  1603. function setConnectionBusy() {
  1604. setConnection(true, 'busy');
  1605. }
  1606. function beginRequest() {
  1607. state.pendingRequests += 1;
  1608. setConnectionBusy();
  1609. render();
  1610. }
  1611. function endRequest() {
  1612. state.pendingRequests = Math.max(0, state.pendingRequests - 1);
  1613. if (state.server.configOk || state.server.runtimeOk) {
  1614. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1615. }
  1616. render();
  1617. }
  1618. async function sendPatch(patch, { successMessage = 'Applied', clearKeys = [] } = {}) {
  1619. beginRequest();
  1620. try {
  1621. const result = await api('/config', {
  1622. method: 'POST',
  1623. headers: { 'Content-Type': 'application/json' },
  1624. body: JSON.stringify(patch),
  1625. });
  1626. if (state.server.config) mergeLocalConfigPatch(patch);
  1627. clearDirty(clearKeys);
  1628. render();
  1629. toast(successMessage + (result.live ? ' · live' : ''), 'ok');
  1630. log('PATCH ' + JSON.stringify(patch) + (result.live ? ' [live]' : ' [saved]'), 'ok');
  1631. await Promise.allSettled([
  1632. loadConfig({ silent: true }),
  1633. loadRuntime({ silent: true }),
  1634. ]);
  1635. return result;
  1636. } catch (error) {
  1637. toast(error.message, 'err');
  1638. log('PATCH failed: ' + error.message, 'err');
  1639. throw error;
  1640. } finally {
  1641. endRequest();
  1642. }
  1643. }
  1644. function mergeLocalConfigPatch(patch) {
  1645. const cfg = state.server.config || {};
  1646. cfg.fm ||= {};
  1647. cfg.rds ||= {};
  1648. for (const [key, value] of Object.entries(patch)) {
  1649. switch (key) {
  1650. case 'frequencyMHz': cfg.fm.frequencyMHz = value; break;
  1651. case 'stereoEnabled': cfg.fm.stereoEnabled = value; break;
  1652. case 'limiterEnabled': cfg.fm.limiterEnabled = value; break;
  1653. case 'rdsEnabled': cfg.rds.enabled = value; break;
  1654. case 'ps': cfg.rds.ps = value; break;
  1655. case 'radioText': cfg.rds.radioText = value; break;
  1656. }
  1657. }
  1658. }
  1659. async function applySection(section) {
  1660. if (sectionHasErrors(section)) {
  1661. toast('Fix the highlighted fields first', 'warn');
  1662. log(section.toUpperCase() + ' apply blocked by validation errors', 'warn');
  1663. render();
  1664. return;
  1665. }
  1666. const patch = getSectionPatch(section);
  1667. const keys = Object.keys(patch);
  1668. if (!keys.length) {
  1669. toast('No changes to apply', 'info');
  1670. return;
  1671. }
  1672. let message = 'Applied';
  1673. if (section === 'freq') message = 'Frequency updated';
  1674. if (section === 'rds') message = 'RDS text updated';
  1675. await sendPatch(patch, { successMessage: message, clearKeys: keys });
  1676. }
  1677. function resetSection(section) {
  1678. const keys = Object.keys(fields).filter((key) => fields[key].section === section);
  1679. clearDirty(keys);
  1680. log(section.toUpperCase() + ' draft reset', 'warn');
  1681. toast('Draft reset', 'info');
  1682. }
  1683. async function setToggle(key, nextValue) {
  1684. if (state.toggleBusy[key]) return;
  1685. state.toggleBusy[key] = true;
  1686. render();
  1687. try {
  1688. await sendPatch({ [key]: nextValue }, {
  1689. successMessage: key.replace(/Enabled$/, '') + ' ' + (nextValue ? 'enabled' : 'disabled'),
  1690. clearKeys: [],
  1691. });
  1692. } finally {
  1693. state.toggleBusy[key] = false;
  1694. render();
  1695. }
  1696. }
  1697. async function txAction(action) {
  1698. if (state.txBusy) return;
  1699. state.txBusy = true;
  1700. render();
  1701. beginRequest();
  1702. try {
  1703. await api(`/tx/${action}`, { method: 'POST' });
  1704. toast(action === 'start' ? 'TX started' : 'TX stopped', 'ok');
  1705. log('TX ' + action + ' request accepted', 'ok');
  1706. await Promise.allSettled([
  1707. loadRuntime({ silent: true }),
  1708. loadConfig({ silent: true }),
  1709. ]);
  1710. } catch (error) {
  1711. toast(error.message, 'err');
  1712. log('TX ' + action + ' failed: ' + error.message, 'err');
  1713. } finally {
  1714. state.txBusy = false;
  1715. endRequest();
  1716. render();
  1717. }
  1718. }
  1719. async function resetFaultAction() {
  1720. if (state.faultResetBusy) return;
  1721. state.faultResetBusy = true;
  1722. render();
  1723. beginRequest();
  1724. try {
  1725. await api('/runtime/fault/reset', { method: 'POST' });
  1726. toast('Fault reset', 'ok');
  1727. log('Fault reset request accepted', 'ok');
  1728. await loadRuntime({ silent: true });
  1729. } catch (error) {
  1730. toast(error.message, 'err');
  1731. log('Fault reset failed: ' + error.message, 'err');
  1732. } finally {
  1733. state.faultResetBusy = false;
  1734. endRequest();
  1735. render();
  1736. }
  1737. }
  1738. function fmt(n) {
  1739. if (n == null) return '--';
  1740. if (n >= 1e9) return (n / 1e9).toFixed(2) + 'G';
  1741. if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
  1742. if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
  1743. return String(n);
  1744. }
  1745. function fmtTime(seconds) {
  1746. if (!seconds || seconds <= 0) return '--';
  1747. const h = Math.floor(seconds / 3600);
  1748. const m = Math.floor((seconds % 3600) / 60);
  1749. const s = Math.floor(seconds % 60);
  1750. if (h > 0) return `${h}h ${m}m`;
  1751. if (m > 0) return `${m}m ${s}s`;
  1752. return `${s}s`;
  1753. }
  1754. function fmtDurationSeconds(value) {
  1755. if (!Number.isFinite(value) || value < 0) return '--';
  1756. if (value >= 1) return `${value.toFixed(2)} s`;
  1757. return `${(value * 1000).toFixed(0)} ms`;
  1758. }
  1759. function fmtBool(v) {
  1760. return v == null ? '--' : (v ? 'ON' : 'OFF');
  1761. }
  1762. function fmtFreq(v) {
  1763. return typeof v === 'number' ? v.toFixed(1) + ' MHz' : '--';
  1764. }
  1765. function fmtPercent(v) {
  1766. if (typeof v !== 'number') return '--';
  1767. return (v * 100).toFixed(0) + '%';
  1768. }
  1769. function ageString(ts) {
  1770. if (!ts) return '--';
  1771. const diff = Math.max(0, Math.floor((nowTs() - ts) / 1000));
  1772. if (diff < 2) return 'just now';
  1773. if (diff < 60) return diff + 's ago';
  1774. const m = Math.floor(diff / 60);
  1775. if (m < 60) return m + 'm ago';
  1776. const h = Math.floor(m / 60);
  1777. return h + 'h ago';
  1778. }
  1779. function updateText(id, text) {
  1780. const el = $(id);
  1781. if (el && el.textContent !== String(text)) el.textContent = text;
  1782. }
  1783. function updateHTML(id, html) {
  1784. const el = $(id);
  1785. if (el && el.innerHTML !== html) el.innerHTML = html;
  1786. }
  1787. function syncDirtyInput(id, key, transform = (v) => v) {
  1788. const el = $(id);
  1789. if (!el) return;
  1790. const dirty = state.dirty.has(key);
  1791. const desired = dirty ? transform(state.draft[key]) : transform(serverValue(key));
  1792. const asString = desired == null ? '' : String(desired);
  1793. const isFocused = document.activeElement === el;
  1794. if (!isFocused || !dirty) {
  1795. if (el.value !== asString) el.value = asString;
  1796. }
  1797. el.classList.toggle('input-dirty', dirty && !state.errors[key]);
  1798. el.classList.toggle('input-error', !!state.errors[key]);
  1799. }
  1800. function renderFieldErrors() {
  1801. renderFieldError('freq-error', state.errors.frequencyMHz);
  1802. renderFieldError('ps-error', state.errors.ps);
  1803. renderFieldError('rt-error', state.errors.radioText);
  1804. }
  1805. function renderFieldError(id, message) {
  1806. const el = $(id);
  1807. el.textContent = message || '';
  1808. el.classList.toggle('show', !!message);
  1809. }
  1810. function setMeter(fillId, textId, ratio, text, mode = 'good') {
  1811. const fill = $(fillId);
  1812. const pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100)));
  1813. fill.style.width = pct + '%';
  1814. fill.className = 'meter-fill' + (mode === 'warn' ? ' warn' : mode === 'err' ? ' err' : '');
  1815. updateText(textId, text);
  1816. }
  1817. function applyMobilePanelDefaults() {
  1818. if (!mobileMq.matches || state.mobilePanelsApplied) return;
  1819. state.mobilePanelsApplied = true;
  1820. document.querySelectorAll('.panel[data-panel-key]').forEach((panel) => {
  1821. const key = panel.dataset.panelKey;
  1822. const head = panel.querySelector('.panel-head');
  1823. const body = panel.querySelector('.panel-body');
  1824. const keepOpen = key === 'frequency' || key === 'danger';
  1825. head.classList.toggle('collapsed', !keepOpen);
  1826. body.classList.toggle('collapsed', !keepOpen);
  1827. });
  1828. }
  1829. function syncFreqPresetIndex(value) {
  1830. if (typeof value !== 'number') return;
  1831. let closestIndex = 0;
  1832. let closestDistance = Infinity;
  1833. freqPresetValues.forEach((freq, idx) => {
  1834. const dist = Math.abs(freq - value);
  1835. if (dist < closestDistance) {
  1836. closestDistance = dist;
  1837. closestIndex = idx;
  1838. }
  1839. });
  1840. state.freqPresetIndex = closestIndex;
  1841. }
  1842. function cycleFreqPreset(direction) {
  1843. state.freqPresetIndex = (state.freqPresetIndex + direction + freqPresetValues.length) % freqPresetValues.length;
  1844. const next = freqPresetValues[state.freqPresetIndex];
  1845. setDirty('frequencyMHz', next);
  1846. toast(`Preset ${next.toFixed(1)} MHz loaded`, 'info');
  1847. }
  1848. function refreshPresetButtons() {
  1849. const effectiveFreq = effectiveValue('frequencyMHz');
  1850. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  1851. const value = Number(btn.dataset.freqPreset);
  1852. btn.classList.toggle('active', typeof effectiveFreq === 'number' && nearlyEqual(value, effectiveFreq, 0.05));
  1853. });
  1854. }
  1855. function updateHeroState(engineState) {
  1856. const hero = $('hero-card');
  1857. hero.classList.remove('tx-live', 'tx-busy');
  1858. if (state.txBusy || ['starting', 'stopping'].includes(engineState)) {
  1859. hero.classList.add('tx-busy');
  1860. } else if (engineState === 'running') {
  1861. hero.classList.add('tx-live');
  1862. }
  1863. }
  1864. function drawSparkline(svgId, values, mode = 'good', maxOverride = null) {
  1865. const svg = $(svgId);
  1866. if (!svg) return;
  1867. svg.className = `spark ${mode}`;
  1868. const width = 160;
  1869. const height = 34;
  1870. const points = values.length ? values : [0, 0];
  1871. const max = maxOverride != null ? maxOverride : Math.max(...points, 1);
  1872. const min = 0;
  1873. const step = points.length <= 1 ? width : width / (points.length - 1);
  1874. const coords = points.map((value, i) => {
  1875. const x = i * step;
  1876. const norm = max === min ? 0 : (value - min) / (max - min || 1);
  1877. const y = height - 4 - norm * (height - 8);
  1878. return [x, y];
  1879. });
  1880. const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
  1881. const area = `${line} L ${width},${height} L 0,${height} Z`;
  1882. svg.innerHTML = `<path class="area" d="${area}"></path><path class="line" d="${line}"></path>`;
  1883. }
  1884. function render() {
  1885. const cfg = state.server.config || {};
  1886. const runtime = state.server.runtime || {};
  1887. const engine = runtime.engine || {};
  1888. const driver = runtime.driver || {};
  1889. const audioStream = runtime.audioStream || null;
  1890. const appliedRaw = engine.appliedFrequencyMHz;
  1891. const appliedFreq = Number.isFinite(Number(appliedRaw)) ? Number(appliedRaw) : null;
  1892. const desiredRaw = cfg.fm?.frequencyMHz;
  1893. const desiredFreq = Number.isFinite(Number(desiredRaw)) ? Number(desiredRaw) : null;
  1894. const displayFreq = appliedFreq ?? effectiveValue('frequencyMHz') ?? desiredFreq;
  1895. updateHTML('freq-display', `${typeof displayFreq === 'number' ? displayFreq.toFixed(1) : '---.-'}<span class="unit">MHz</span>`);
  1896. const appliedLabel = appliedFreq != null ? `Applied ${appliedFreq.toFixed(1)} MHz` : 'Applied --';
  1897. const desiredLabel = desiredFreq != null ? `Desired ${desiredFreq.toFixed(1)} MHz` : 'Desired --';
  1898. updateText('freq-applied', appliedLabel);
  1899. updateText('freq-desired', desiredLabel);
  1900. const noteEl = $('freq-note');
  1901. if (noteEl) {
  1902. const mismatch = appliedFreq != null && desiredFreq != null && !nearlyEqual(appliedFreq, desiredFreq, 0.001);
  1903. noteEl.classList.toggle('mismatch', mismatch);
  1904. }
  1905. updateText('badge-backend', cfg.backend?.kind || cfg.backend || '--');
  1906. updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane');
  1907. updateText('badge-live', state.server.runtimeOk ? 'Connected' : 'Waiting');
  1908. updateText('t-chunks', fmt(engine.chunksProduced));
  1909. updateText('t-samples', fmt(engine.totalSamples));
  1910. updateText('t-uptime', fmtTime(engine.uptimeSeconds));
  1911. updateText('t-rate', driver.effectiveSampleRateHz ? (driver.effectiveSampleRateHz / 1000).toFixed(0) + 'k' : '--');
  1912. const underruns = engine.underruns ?? driver.underruns;
  1913. const underrunEl = $('t-underruns');
  1914. underrunEl.textContent = underruns == null ? '--' : String(underruns);
  1915. underrunEl.className = 'value' + (underruns > 0 ? ' err' : underruns === 0 ? ' good' : '');
  1916. const txStateValue = String(engine.state || 'idle').toLowerCase();
  1917. const txState = state.txBusy ? 'WORKING…' : txStateValue.toUpperCase();
  1918. const txClass = state.txBusy ? 'working' : txStateValue;
  1919. $('tx-state').textContent = txState;
  1920. $('tx-state').className = 'tx-state ' + txClass;
  1921. updateText('tx-hint', engine.lastError ? `Last error: ${engine.lastError}` : (state.txBusy ? 'Command in progress' : 'Runtime polled every 1s'));
  1922. updateHeroState(txStateValue);
  1923. const startDisabled = state.txBusy || txStateValue === 'running';
  1924. const stopDisabled = state.txBusy || ['idle', 'stopped', ''].includes(txStateValue);
  1925. $('btn-start').disabled = startDisabled;
  1926. $('btn-stop').disabled = stopDisabled;
  1927. $('btn-refresh').disabled = state.pendingRequests > 0;
  1928. $('danger-stop').disabled = stopDisabled;
  1929. $('danger-refresh').disabled = state.pendingRequests > 0;
  1930. const resetFaultBtn = $('danger-reset-fault');
  1931. if (resetFaultBtn) {
  1932. const resetDisabled = state.faultResetBusy || !state.server.runtimeOk;
  1933. resetFaultBtn.disabled = resetDisabled;
  1934. resetFaultBtn.textContent = state.faultResetBusy ? 'Resetting…' : 'Reset Fault';
  1935. }
  1936. syncDirtyInput('freq-slider', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  1937. syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  1938. syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? ''));
  1939. syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? ''));
  1940. const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? '');
  1941. const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? '');
  1942. updateText('ps-count', psValue.length);
  1943. updateText('rt-count', rtValue.length);
  1944. renderFieldErrors();
  1945. refreshPresetButtons();
  1946. renderToggle('stereoEnabled', 'tog-stereo', 'stereo-label');
  1947. renderToggle('rdsEnabled', 'tog-rds', 'rds-label');
  1948. renderToggle('limiterEnabled', 'tog-limiter', 'limiter-label');
  1949. const freqDirty = isDirtySection('freq');
  1950. $('freq-apply').disabled = !freqDirty || sectionHasErrors('freq');
  1951. $('freq-reset').disabled = !freqDirty;
  1952. const rdsDirty = isDirtySection('rds');
  1953. $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds');
  1954. $('rds-reset').disabled = !rdsDirty;
  1955. updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable'));
  1956. updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT'));
  1957. updateText('info-backend', cfg.backend?.kind || cfg.backend || '--');
  1958. updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz));
  1959. updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off');
  1960. updateText('info-fmmod', fmtBool(cfg.fm?.fmModulationEnabled));
  1961. updateText('info-live', engine.state ? `${String(engine.state).toUpperCase()} / ${state.server.runtimeOk ? 'runtime ok' : 'runtime pending'}` : (state.server.configOk ? 'config only' : '--'));
  1962. updateHealth(engine, driver, audioStream);
  1963. updateControlAudit(runtime.controlAudit);
  1964. updateFaultHistory(engine);
  1965. updateTransitionHistory();
  1966. updateResetHint(engine);
  1967. updateMeters(engine, driver, audioStream);
  1968. const highWatermarkDurationSecondsRaw = audioStream?.highWatermarkDurationSeconds;
  1969. const highWatermarkDurationSeconds = Number(highWatermarkDurationSecondsRaw);
  1970. const highWatermarkFramesRaw = audioStream?.highWatermark;
  1971. const highWatermarkFrames = Number.isFinite(Number(highWatermarkFramesRaw)) ? Number(highWatermarkFramesRaw) : 0;
  1972. const capacityRaw = audioStream?.capacity;
  1973. const capacity = Number.isFinite(Number(capacityRaw)) ? Number(capacityRaw) : 0;
  1974. const bufferedDurationSecondsRaw = audioStream?.bufferedDurationSeconds;
  1975. const bufferedDurationSeconds = Number(bufferedDurationSecondsRaw);
  1976. const hasBufferedDuration = Number.isFinite(bufferedDurationSeconds);
  1977. const hasHighWatermarkDuration = Number.isFinite(highWatermarkDurationSeconds);
  1978. const highWatermarkRatio = capacity > 0 ? Math.min(1, highWatermarkFrames / capacity) : 0;
  1979. let highWatermarkMode = 'good';
  1980. if (highWatermarkRatio >= 0.95) highWatermarkMode = 'err';
  1981. else if (highWatermarkRatio >= 0.65) highWatermarkMode = 'warn';
  1982. const sparkHighWatermarkMax = Math.max(
  1983. 1,
  1984. hasHighWatermarkDuration ? highWatermarkDurationSeconds : 0,
  1985. hasBufferedDuration ? bufferedDurationSeconds : 0
  1986. );
  1987. const queueHealthRaw = String(engine.queue?.health || '').toLowerCase();
  1988. let queueSparkMode = 'good';
  1989. if (queueHealthRaw === 'critical') queueSparkMode = 'err';
  1990. else if (queueHealthRaw === 'low') queueSparkMode = 'warn';
  1991. drawSparkline('spark-audio', state.charts.audio, 'good', 1);
  1992. drawSparkline('spark-high-watermark', state.charts.highWatermark, highWatermarkMode, sparkHighWatermarkMax);
  1993. drawSparkline('spark-queue-fill', state.charts.queueFill, queueSparkMode, 1);
  1994. drawSparkline('spark-underruns', state.charts.underruns, underruns > 0 ? 'err' : 'warn');
  1995. drawSparkline('spark-tx', state.charts.tx, txStateValue === 'running' ? 'good' : 'warn', 1);
  1996. applyMobilePanelDefaults();
  1997. }
  1998. function renderToggle(key, toggleId, labelId) {
  1999. const on = !!serverValue(key);
  2000. const busy = !!state.toggleBusy[key];
  2001. const el = $(toggleId);
  2002. el.className = 'toggle' + (on ? ' on' : '') + (busy ? ' busy' : '');
  2003. el.setAttribute('aria-checked', on ? 'true' : 'false');
  2004. updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF'));
  2005. }
  2006. function runtimeStateClass(engineState) {
  2007. const normalized = String(engineState || '').toLowerCase();
  2008. if (!normalized) {
  2009. return 'warn';
  2010. }
  2011. switch (normalized) {
  2012. case 'faulted':
  2013. return 'err';
  2014. case 'muted':
  2015. case 'degraded':
  2016. case 'prebuffering':
  2017. case 'arming':
  2018. case 'stopping':
  2019. case 'idle':
  2020. case 'unknown':
  2021. return 'warn';
  2022. default:
  2023. return 'good';
  2024. }
  2025. }
  2026. function normalizeRuntimeState(stateName) {
  2027. const normalized = (typeof stateName === 'string' ? stateName.trim().toLowerCase() : '');
  2028. return normalized || 'idle';
  2029. }
  2030. function runtimeStateSeverity(stateName) {
  2031. const normalized = normalizeRuntimeState(stateName);
  2032. switch (normalized) {
  2033. case 'running':
  2034. return 'ok';
  2035. case 'degraded':
  2036. case 'muted':
  2037. return 'warn';
  2038. case 'faulted':
  2039. return 'err';
  2040. default:
  2041. return 'info';
  2042. }
  2043. }
  2044. function notifyRuntimeTransition(engine, pushHistory = true) {
  2045. if (!engine) return;
  2046. const next = normalizeRuntimeState(engine.state);
  2047. const prev = state.lastRuntimeState;
  2048. state.lastRuntimeState = next;
  2049. if (!prev || prev === next) return;
  2050. const severity = runtimeStateSeverity(next);
  2051. if (pushHistory) {
  2052. pushTransitionHistory(prev, next, severity);
  2053. }
  2054. const message = `Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`;
  2055. const logLevel = severity === 'err' ? 'err' : (severity === 'warn' ? 'warn' : 'info');
  2056. toast(message, severity);
  2057. log(message, logLevel);
  2058. }
  2059. function updateHealth(engine, driver, audioStream) {
  2060. engine = engine || {};
  2061. driver = driver || {};
  2062. updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE');
  2063. $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err');
  2064. let runtimeLabel = 'WAITING';
  2065. let runtimeClass = 'warn';
  2066. if (state.server.runtimeOk) {
  2067. const engineStateName = String(engine.state || 'unknown');
  2068. runtimeLabel = engineStateName.toUpperCase();
  2069. runtimeClass = runtimeStateClass(engineStateName);
  2070. }
  2071. updateText('health-runtime', runtimeLabel);
  2072. $('health-runtime').className = 'val ' + runtimeClass;
  2073. const durationSeconds = Number(engine.runtimeStateDurationSeconds);
  2074. const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : '--';
  2075. updateText('health-state-age', durationLabel);
  2076. const stateAgeEl = $('health-state-age');
  2077. if (stateAgeEl) {
  2078. stateAgeEl.className = 'val ' + runtimeClass;
  2079. }
  2080. const runtimeIndicator = engine.runtimeIndicator;
  2081. const indicatorLabels = {
  2082. normal: 'Normal',
  2083. degraded: 'Degraded',
  2084. queueCritical: 'Queue critical',
  2085. };
  2086. const indicatorText = indicatorLabels[runtimeIndicator] || (runtimeIndicator ? runtimeIndicator : '--');
  2087. let indicatorSeverity = '';
  2088. if (runtimeIndicator === 'queueCritical') indicatorSeverity = 'err';
  2089. else if (runtimeIndicator === 'degraded') indicatorSeverity = 'warn';
  2090. else if (runtimeIndicator === 'normal') indicatorSeverity = 'good';
  2091. const indicatorEl = $('health-indicator');
  2092. if (indicatorEl) {
  2093. indicatorEl.className = 'val' + (indicatorSeverity ? ' ' + indicatorSeverity : '');
  2094. }
  2095. updateText('health-indicator', indicatorText);
  2096. const runtimeAlertRaw = (engine.runtimeAlert || '').trim();
  2097. const hasAlert = !!runtimeAlertRaw;
  2098. const alertEl = $('health-alert');
  2099. if (alertEl) {
  2100. alertEl.className = 'val ' + (hasAlert ? 'warn' : 'good');
  2101. }
  2102. updateText('health-alert', hasAlert ? runtimeAlertRaw : 'None');
  2103. let audioLabel = 'N/A';
  2104. let audioClass = 'val';
  2105. if (audioStream) {
  2106. const fill = typeof audioStream.buffered === 'number' ? audioStream.buffered : null;
  2107. audioLabel = fill == null ? 'Unknown' : fmtPercent(fill);
  2108. if (fill == null) audioClass = 'val';
  2109. else if (fill < 0.05) audioClass = 'val err';
  2110. else if (fill < 0.2) audioClass = 'val warn';
  2111. else audioClass = 'val good';
  2112. }
  2113. updateText('health-audio', audioLabel);
  2114. $('health-audio').className = audioClass;
  2115. const bufferedDurationSeconds = Number(audioStream?.bufferedDurationSeconds);
  2116. updateText('health-buffer-duration', fmtDurationSeconds(bufferedDurationSeconds));
  2117. const highWatermarkRaw = audioStream?.highWatermark;
  2118. const highWatermarkFrames = Number.isFinite(Number(highWatermarkRaw)) ? Number(highWatermarkRaw) : null;
  2119. const highWatermarkDurationRaw = audioStream?.highWatermarkDurationSeconds;
  2120. const highWatermarkDuration = Number.isFinite(Number(highWatermarkDurationRaw)) ? Number(highWatermarkDurationRaw) : null;
  2121. let highWatermarkLabel = '--';
  2122. if (highWatermarkDuration !== null) {
  2123. highWatermarkLabel = fmtDurationSeconds(highWatermarkDuration);
  2124. if (highWatermarkFrames !== null) {
  2125. highWatermarkLabel += ` (${highWatermarkFrames} frames)`;
  2126. }
  2127. } else if (highWatermarkFrames !== null) {
  2128. highWatermarkLabel = `${highWatermarkFrames} frames`;
  2129. }
  2130. updateText('health-buffer-highwater', highWatermarkLabel);
  2131. const queueFill = Number(engine.queue?.fillLevel);
  2132. const queueHealthRaw = String(engine.queue?.health || '').toLowerCase();
  2133. const queueHealthLabel = queueHealthRaw ? queueHealthRaw[0].toUpperCase() + queueHealthRaw.slice(1) : '';
  2134. let queueFillLabel = '--';
  2135. if (Number.isFinite(queueFill)) {
  2136. queueFillLabel = fmtPercent(queueFill);
  2137. if (queueHealthLabel) queueFillLabel += ` · ${queueHealthLabel}`;
  2138. } else if (queueHealthLabel) {
  2139. queueFillLabel = queueHealthLabel;
  2140. }
  2141. updateText('health-queue-fill', queueFillLabel);
  2142. const queueFillEl = $('health-queue-fill');
  2143. if (queueFillEl) {
  2144. let queueFillClass = 'good';
  2145. if (queueHealthRaw === 'critical') queueFillClass = 'err';
  2146. else if (queueHealthRaw === 'low') queueFillClass = 'warn';
  2147. queueFillEl.className = 'val ' + queueFillClass;
  2148. }
  2149. const streakEl = $('health-underrun-streak');
  2150. if (streakEl) {
  2151. const streakRaw = driver?.underrunStreak;
  2152. const streakMaxRaw = driver?.maxUnderrunStreak;
  2153. const streakCurrent = Number.isFinite(Number(streakRaw)) ? Number(streakRaw) : null;
  2154. const streakMax = Number.isFinite(Number(streakMaxRaw)) ? Number(streakMaxRaw) : null;
  2155. let streakLabel = '--';
  2156. if (streakCurrent != null) {
  2157. streakLabel = String(streakCurrent);
  2158. if (streakMax != null) {
  2159. streakLabel += ` (max ${streakMax})`;
  2160. }
  2161. } else if (streakMax != null) {
  2162. streakLabel = `Max ${streakMax}`;
  2163. }
  2164. let streakSeverity = '';
  2165. if (streakCurrent != null || streakMax != null) {
  2166. const highestStreak = Math.max(
  2167. streakCurrent != null ? streakCurrent : 0,
  2168. streakMax != null ? streakMax : 0
  2169. );
  2170. if (highestStreak >= 6) streakSeverity = ' err';
  2171. else if (highestStreak > 0) streakSeverity = ' warn';
  2172. else streakSeverity = ' good';
  2173. }
  2174. streakEl.textContent = streakLabel;
  2175. streakEl.className = 'val' + streakSeverity;
  2176. }
  2177. const last = Math.max(state.server.lastConfigAt || 0, state.server.lastRuntimeAt || 0);
  2178. updateText('health-last', ageString(last));
  2179. const transitionsAvailable = engine.degradedTransitions != null || engine.mutedTransitions != null || engine.faultedTransitions != null;
  2180. const transitionsText = transitionsAvailable ? `${Number(engine.degradedTransitions ?? 0)} / ${Number(engine.mutedTransitions ?? 0)} / ${Number(engine.faultedTransitions ?? 0)}` : '--';
  2181. updateText('health-transitions', transitionsText);
  2182. const faultCountValue = engine.faultCount != null ? Number(engine.faultCount) : 0;
  2183. const hasFaultCount = engine.faultCount != null;
  2184. updateText('health-fault-count', hasFaultCount ? String(faultCountValue) : '--');
  2185. const faultCountEl = $('health-fault-count');
  2186. if (faultCountEl) {
  2187. faultCountEl.className = 'val' + (hasFaultCount ? (faultCountValue > 0 ? ' warn' : ' good') : '');
  2188. }
  2189. const lastFaultEl = $('health-last-fault');
  2190. const lastFault = engine.lastFault;
  2191. if (lastFaultEl) {
  2192. if (lastFault) {
  2193. const severity = String(lastFault.severity || '').toLowerCase();
  2194. const severityClass = severity === 'faulted' ? 'err' : 'warn';
  2195. const severityLabel = (lastFault.severity || 'Fault').toUpperCase();
  2196. const reasonLabel = lastFault.reason ? ` ${lastFault.reason}` : '';
  2197. const messageLabel = lastFault.message ? ` - ${lastFault.message}` : '';
  2198. let whenLabel = '';
  2199. if (lastFault.time) {
  2200. const parsed = new Date(lastFault.time);
  2201. if (!Number.isNaN(parsed.getTime())) {
  2202. whenLabel = ` @ ${parsed.toLocaleTimeString()}`;
  2203. }
  2204. }
  2205. const title = `${severityLabel}${reasonLabel}`;
  2206. updateText('health-last-fault', `${title}${messageLabel}${whenLabel}`);
  2207. lastFaultEl.className = 'val ' + severityClass;
  2208. } else {
  2209. lastFaultEl.className = 'val good';
  2210. updateText('health-last-fault', 'None');
  2211. }
  2212. }
  2213. }
  2214. function updateControlAudit(audit) {
  2215. const entries = [
  2216. { key: 'methodNotAllowed', id: 'audit-methodNotAllowed' },
  2217. { key: 'unsupportedMediaType', id: 'audit-unsupportedMediaType' },
  2218. { key: 'bodyTooLarge', id: 'audit-bodyTooLarge' },
  2219. { key: 'unexpectedBody', id: 'audit-unexpectedBody' },
  2220. ];
  2221. let total = 0;
  2222. let hasData = false;
  2223. entries.forEach(({ key, id }) => {
  2224. const raw = audit && typeof audit[key] !== 'undefined' ? Number(audit[key]) : NaN;
  2225. const value = Number.isFinite(raw) ? raw : null;
  2226. if (value != null) {
  2227. hasData = true;
  2228. total += value;
  2229. }
  2230. setAuditValue(id, value);
  2231. });
  2232. setAuditValue('audit-total', hasData ? total : null);
  2233. }
  2234. function setAuditValue(id, count) {
  2235. const el = $(id);
  2236. if (!el) return;
  2237. if (count == null) {
  2238. el.textContent = '--';
  2239. el.className = 'val';
  2240. return;
  2241. }
  2242. el.textContent = String(count);
  2243. el.className = 'val ' + (count > 0 ? 'warn' : 'good');
  2244. }
  2245. function updateFaultHistory(engine) {
  2246. const container = $('fault-history');
  2247. if (!container) return;
  2248. const history = Array.isArray(engine?.faultHistory) ? engine.faultHistory : [];
  2249. if (!history.length) {
  2250. container.innerHTML = '<div class="fault-history-empty">No faults recorded yet.</div>';
  2251. return;
  2252. }
  2253. const rows = history.slice().reverse().map((entry) => {
  2254. const when = entry?.time ? new Date(entry.time) : null;
  2255. const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--';
  2256. const severity = String(entry?.severity || 'warn').toLowerCase();
  2257. const severityLabel = String(entry?.severity || 'Fault').toUpperCase();
  2258. const reasonLabel = entry?.reason ? ` ${entry.reason}` : '';
  2259. const messageLabel = entry?.message ? ` · ${entry.message}` : '';
  2260. return `<div class="fault-history-entry ${severity}"><span class="fault-history-time">${timeLabel}</span><span class="fault-history-desc">${severityLabel}${reasonLabel}${messageLabel}</span></div>`;
  2261. });
  2262. container.innerHTML = rows.join('');
  2263. }
  2264. function updateResetHint(engine) {
  2265. const hint = $('reset-hint');
  2266. if (!hint) return;
  2267. const stateName = String(engine?.state || '').toLowerCase();
  2268. let text = 'Manual fault reset drops runtime to DEGRADED while the queue recovers.';
  2269. if (stateName === 'faulted') {
  2270. text = 'Faulted: reset moves runtime back to DEGRADED until the queue settles.';
  2271. } else if (stateName === 'muted' || stateName === 'degraded') {
  2272. text = 'Reset Fault keeps the runtime in DEGRADED so the queue can recover before running again.';
  2273. }
  2274. const durationSeconds = Number(engine?.runtimeStateDurationSeconds);
  2275. const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : null;
  2276. const ageHint = durationLabel ? ` State age ${durationLabel}.` : '';
  2277. hint.textContent = text + ageHint;
  2278. }
  2279. function updateMeters(engine, driver, audioStream) {
  2280. if (audioStream && typeof audioStream.buffered === 'number') {
  2281. const ratio = Math.max(0, Math.min(1, audioStream.buffered));
  2282. const mode = ratio < 0.05 ? 'err' : ratio < 0.2 ? 'warn' : 'good';
  2283. setMeter('meter-audio-fill', 'meter-audio-text', ratio, fmtPercent(ratio), mode);
  2284. } else {
  2285. setMeter('meter-audio-fill', 'meter-audio-text', 0, 'N/A', 'warn');
  2286. }
  2287. const underruns = Number(engine.underruns ?? driver.underruns ?? 0);
  2288. const healthRatio = underruns <= 0 ? 1 : Math.max(0, 1 - Math.min(underruns, 10) / 10);
  2289. const healthMode = underruns === 0 ? 'good' : underruns < 3 ? 'warn' : 'err';
  2290. setMeter('meter-stream-fill', 'meter-stream-text', healthRatio, underruns === 0 ? 'Clean' : `${underruns} underrun${underruns === 1 ? '' : 's'}`, healthMode);
  2291. const txState = String(engine.state || 'idle').toLowerCase();
  2292. const activeRatio = txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.08;
  2293. const activeMode = txState === 'running' ? 'good' : state.txBusy ? 'warn' : 'err';
  2294. const activeText = txState === 'running' ? 'Live' : state.txBusy ? 'Working' : 'Idle';
  2295. setMeter('meter-tx-fill', 'meter-tx-text', activeRatio, activeText, activeMode);
  2296. }
  2297. function toast(msg, type = 'info') {
  2298. const t = $('toast');
  2299. t.textContent = msg;
  2300. t.className = 'toast ' + type + ' show';
  2301. clearTimeout(t._timer);
  2302. t._timer = setTimeout(() => t.classList.remove('show'), 2600);
  2303. }
  2304. function log(message, type = '') {
  2305. const logEl = $('log');
  2306. const empty = logEl.querySelector('.empty-log');
  2307. if (empty) empty.remove();
  2308. const row = document.createElement('div');
  2309. row.className = 'entry ' + type;
  2310. row.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  2311. logEl.appendChild(row);
  2312. while (logEl.children.length > 250) logEl.removeChild(logEl.firstChild);
  2313. logEl.scrollTop = logEl.scrollHeight;
  2314. }
  2315. function bindPanels() {
  2316. document.querySelectorAll('[data-panel]').forEach((head) => {
  2317. head.addEventListener('click', () => {
  2318. head.classList.toggle('collapsed');
  2319. head.nextElementSibling.classList.toggle('collapsed');
  2320. });
  2321. });
  2322. }
  2323. function bindInputs() {
  2324. $('freq-slider').addEventListener('input', (e) => {
  2325. const value = Number(e.target.value);
  2326. setDirty('frequencyMHz', value);
  2327. });
  2328. $('freq-num').addEventListener('input', (e) => {
  2329. const value = Number(e.target.value);
  2330. if (!Number.isNaN(value)) setDirty('frequencyMHz', value);
  2331. else {
  2332. state.errors.frequencyMHz = 'Enter a valid number.';
  2333. render();
  2334. }
  2335. });
  2336. $('rds-ps').addEventListener('input', (e) => setDirty('ps', e.target.value.toUpperCase().slice(0, 8)));
  2337. $('rds-rt').addEventListener('input', (e) => setDirty('radioText', e.target.value.slice(0, 64)));
  2338. $('freq-apply').addEventListener('click', () => applySection('freq'));
  2339. $('rds-apply').addEventListener('click', () => applySection('rds'));
  2340. $('freq-reset').addEventListener('click', () => resetSection('freq'));
  2341. $('rds-reset').addEventListener('click', () => resetSection('rds'));
  2342. $('btn-start').addEventListener('click', () => txAction('start'));
  2343. $('btn-stop').addEventListener('click', () => txAction('stop'));
  2344. $('danger-stop').addEventListener('click', () => txAction('stop'));
  2345. $('btn-refresh').addEventListener('click', manualRefresh);
  2346. $('danger-refresh').addEventListener('click', manualRefresh);
  2347. $('danger-reset-fault').addEventListener('click', () => resetFaultAction());
  2348. document.querySelectorAll('.toggle[data-toggle]').forEach((toggle) => {
  2349. const key = toggle.dataset.toggle;
  2350. const handler = () => setToggle(key, !serverValue(key));
  2351. toggle.addEventListener('click', handler);
  2352. toggle.addEventListener('keydown', (e) => {
  2353. if (e.key === 'Enter' || e.key === ' ') {
  2354. e.preventDefault();
  2355. handler();
  2356. }
  2357. });
  2358. });
  2359. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  2360. btn.addEventListener('click', () => {
  2361. const value = Number(btn.dataset.freqPreset);
  2362. setDirty('frequencyMHz', value);
  2363. toast(`Frequency preset ${value.toFixed(1)} MHz loaded`, 'info');
  2364. });
  2365. });
  2366. document.querySelectorAll('[data-rds-ps]').forEach((btn) => {
  2367. btn.addEventListener('click', () => {
  2368. setDirty('ps', btn.dataset.rdsPs || '');
  2369. setDirty('radioText', btn.dataset.rdsRt || '');
  2370. toast('RDS preset loaded', 'info');
  2371. });
  2372. });
  2373. $('btn-clear-log').addEventListener('click', () => {
  2374. $('log').innerHTML = '<div class="empty-log">No events yet.</div>';
  2375. toast('Log cleared', 'info');
  2376. });
  2377. }
  2378. async function manualRefresh() {
  2379. beginRequest();
  2380. try {
  2381. await Promise.allSettled([
  2382. loadConfig({ silent: true }),
  2383. loadRuntime({ silent: true }),
  2384. ]);
  2385. toast('Refreshed', 'info');
  2386. log('Manual refresh completed', 'info');
  2387. } finally {
  2388. endRequest();
  2389. }
  2390. }
  2391. function startPollers() {
  2392. if (state.pollersStarted) return;
  2393. state.pollersStarted = true;
  2394. setInterval(() => { loadRuntime({ silent: true }); }, runtimePollMs);
  2395. setInterval(() => { loadConfig({ silent: true }); }, configPollMs);
  2396. }
  2397. function bindResponsiveBehavior() {
  2398. const listener = () => {
  2399. if (!mobileMq.matches) {
  2400. state.mobilePanelsApplied = false;
  2401. return;
  2402. }
  2403. applyMobilePanelDefaults();
  2404. };
  2405. if (mobileMq.addEventListener) mobileMq.addEventListener('change', listener);
  2406. else mobileMq.addListener(listener);
  2407. }
  2408. function isTypingContext(target) {
  2409. if (!target) return false;
  2410. const tag = (target.tagName || '').toLowerCase();
  2411. return tag === 'input' || tag === 'textarea' || target.isContentEditable;
  2412. }
  2413. function bindKeyboardShortcuts() {
  2414. window.addEventListener('keydown', (e) => {
  2415. if (isTypingContext(e.target)) {
  2416. if (e.key !== 'Enter') return;
  2417. if (state.dirty.has('frequencyMHz')) {
  2418. e.preventDefault();
  2419. applySection('freq');
  2420. } else if (isDirtySection('rds')) {
  2421. e.preventDefault();
  2422. applySection('rds');
  2423. }
  2424. return;
  2425. }
  2426. if (e.key === 't' && !e.shiftKey) {
  2427. e.preventDefault();
  2428. txAction('start');
  2429. return;
  2430. }
  2431. if ((e.key === 'T') || (e.key === 't' && e.shiftKey)) {
  2432. e.preventDefault();
  2433. txAction('stop');
  2434. return;
  2435. }
  2436. if (e.key.toLowerCase() === 'r') {
  2437. e.preventDefault();
  2438. manualRefresh();
  2439. return;
  2440. }
  2441. if (e.key === '[') {
  2442. e.preventDefault();
  2443. cycleFreqPreset(-1);
  2444. return;
  2445. }
  2446. if (e.key === ']') {
  2447. e.preventDefault();
  2448. cycleFreqPreset(1);
  2449. return;
  2450. }
  2451. if (e.key === 'Enter') {
  2452. e.preventDefault();
  2453. if (state.dirty.has('frequencyMHz')) applySection('freq');
  2454. else if (isDirtySection('rds')) applySection('rds');
  2455. }
  2456. });
  2457. }
  2458. async function init() {
  2459. bindPanels();
  2460. bindInputs();
  2461. bindResponsiveBehavior();
  2462. bindKeyboardShortcuts();
  2463. render();
  2464. log('fm-rds-tx control UI booting', 'info');
  2465. await Promise.allSettled([
  2466. loadConfig({ silent: false }),
  2467. loadRuntime({ silent: true }),
  2468. ]);
  2469. render();
  2470. startPollers();
  2471. log('Polling active: runtime 1s, config 8s', 'ok');
  2472. log('Keyboard shortcuts armed', 'info');
  2473. }
  2474. init();
  2475. </script>
  2476. </body>
  2477. </html>