Repository navigation
Expand file tree
/
Copy pathconfig.html
More file actions
1005 lines (958 loc) · 44.8 KB
/
Copy pathconfig.html
File metadata and controls
1005 lines (958 loc) · 44.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CABLE 82 - Control Room</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<style>
/* CABLE 82 control room - an Operate surface.
Identity: tinted charcoal ground (never pure black), the VGA bitmap face
for the masthead and section headers only, a clean system sans for every
control, broadcast blue as the one accent (primary action / focus /
selection), hard edges, no glow, no gradients, single-level panels. */
@font-face {
font-family: "VGA";
src: url("fonts/webplus-ibm-vga-8x16.woff") format("woff");
font-display: swap;
}
:root {
--ground: #16181f;
--panel: #1d2029;
--panel-2: #232733;
--line: #2f3444;
--line-strong: #3c4356;
--ink: #e7e9f0;
--ink-dim: #9aa2b4;
--accent: #4b64e6;
--accent-ink: #f4f6ff;
--danger: #d0483e;
--ok: #3fae67;
--warn: #d6a52a;
--radius: 10px;
--sp-1: 8px;
--sp-2: 16px;
--sp-3: 24px;
--sp-4: 32px;
--sp-5: 48px;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
/* ---- masthead ---- */
header.masthead {
position: sticky;
top: 0;
z-index: 5;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: baseline; gap: var(--sp-2); min-width: 0; }
.bug {
white-space: nowrap;
font-family: "VGA", monospace;
font-size: 22px;
letter-spacing: 1px;
background: var(--ink);
color: var(--panel);
padding: 4px 10px 2px;
text-transform: uppercase;
}
.brand .sub {
font-family: "VGA", monospace;
font-size: 13px;
letter-spacing: 1px;
color: var(--ink-dim);
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.masthead .actions { display: flex; align-items: center; gap: var(--sp-1); }
.masthead-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
}
/* On a phone the wordmark and the version hold the line; the buttons drop
below them, and "Control Room" goes, since the wordmark already says it. */
@media (max-width: 560px) {
.brand .sub { display: none; }
.masthead .actions { width: 100%; }
}
.version {
font-family: var(--mono);
font-size: 12px;
color: var(--ink-dim);
text-decoration: none;
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 1px 9px 2px;
white-space: nowrap;
}
.version:hover { color: var(--ink); border-color: var(--ink-dim); }
/* ---- group nav: the progression, and where you are in it ---- */
.groupnav {
display: flex;
gap: var(--sp-1);
padding: 0 var(--sp-3);
border-top: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: thin;
}
.groupnav a {
color: var(--ink-dim);
text-decoration: none;
font-size: 13.5px;
padding: 10px 2px 8px;
margin-right: var(--sp-2);
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.groupnav a:hover { color: var(--ink); }
.groupnav a.on { color: var(--ink); border-bottom-color: var(--accent); }
main {
max-width: 860px;
margin: 0 auto;
padding: var(--sp-4) var(--sp-3) 140px;
}
/* ---- groups ---- */
section.group { scroll-margin-top: var(--head-h, 120px); }
section.group + section.group { margin-top: var(--sp-5); }
.group-head {
font-family: "VGA", monospace;
font-size: 22px;
letter-spacing: 2px;
text-transform: uppercase;
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-1);
border-bottom: 2px solid var(--line-strong);
}
.group-lede { color: var(--ink-dim); font-size: 14px; margin: calc(-1 * var(--sp-2)) 0 var(--sp-3); }
.release-line { padding: 6px 0; }
.power-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; padding: 4px 0; }
.power-row button.armed { border-color: var(--danger); color: var(--danger); }
/* ---- vitals: a read-only grid of what the machine is doing ---- */
.vitals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2) var(--sp-3);
}
@media (max-width: 560px) { .vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.vital label { display: block; font-size: 12.5px; color: var(--ink-dim); letter-spacing: 0.02em; }
.vital .v { font-size: 18px; font-variant-numeric: tabular-nums; line-height: 1.3; }
.vital .v.warn { color: var(--warn); }
.vital .v.bad { color: #f0938a; }
.vital .sub { font-size: 12.5px; color: var(--ink-dim); }
.vitals-note { font-size: 12.5px; color: var(--ink-dim); margin-top: var(--sp-2); }
/* ---- panels ---- */
section.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--sp-3);
margin-bottom: var(--sp-3);
}
section.panel > h3 {
font-family: "VGA", monospace;
font-size: 16px;
letter-spacing: 1px;
text-transform: uppercase;
margin: 0 0 var(--sp-1);
color: var(--ink);
}
section.panel > .hint {
color: var(--ink-dim);
font-size: 13.5px;
margin: 0 0 var(--sp-3);
}
/* ---- form grid ---- */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2) var(--sp-3);
}
@media (max-width: 560px) { .grid { grid-template-columns: minmax(0, 1fr); } }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field > label { font-size: 13px; color: var(--ink-dim); letter-spacing: 0.02em; }
.field .note { font-size: 12.5px; color: var(--ink-dim); }
input[type="text"], input[type="number"], input[type="url"], select, textarea {
font-family: var(--sans);
font-size: 15px;
color: var(--ink);
background: var(--ground);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 9px 11px;
width: 100%;
transition: border-color 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
input.mono { font-family: var(--mono); font-size: 13.5px; }
input::placeholder, textarea::placeholder { color: #6b7183; }
input:hover, select:hover, textarea:hover { border-color: #4a5268; }
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
}
textarea {
min-height: 170px;
line-height: 1.5;
resize: vertical;
}
.count { font-size: 12.5px; color: var(--ink-dim); }
/* ---- toggles, sliders, track list ---- */
input[type="checkbox"], input[type="range"] { accent-color: var(--accent); }
input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; }
input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle { display: flex; align-items: center; gap: 10px; padding: 3px 0; }
.toggle label { color: var(--ink); font-size: 15px; cursor: pointer; }
.range-row { display: flex; align-items: center; gap: var(--sp-2); }
.range-row input[type="range"] { flex: 1; }
.range-row output { min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink); }
.track-list { display: flex; flex-direction: column; gap: 3px; font-size: 14px; color: var(--ink); }
.track-list .num { color: var(--ink-dim); font-variant-numeric: tabular-nums; margin-right: 8px; }
/* ---- weather location search ---- */
.wx-current { font-size: 15px; padding: 6px 0 10px; }
.wx-current .muted { color: var(--ink-dim); }
.wx-search { display: flex; gap: var(--sp-1); }
.wx-search input { flex: 1; }
.wx-results { display: flex; flex-direction: column; gap: 4px; margin-top: var(--sp-1); }
.wx-results:empty { display: none; }
.wx-result { justify-content: flex-start; text-align: left; font-weight: 500; }
select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%239aa2b4' stroke-width='1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 11px center;
padding-right: 30px;
}
/* ---- buttons ---- */
button {
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
transition: background 160ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 160ms;
}
button:hover { background: #2b3040; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
button:active { background: #313749; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
button.primary:hover { background: #5a72ec; }
button.primary:active { background: #4258d0; }
button.ghost { background: transparent; }
.jarvis-try { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); }
button.small { font-size: 13px; padding: 6px 10px; }
/* A link dressed as a button, for the masthead's jumps to the other pages. */
a.button {
display: inline-block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
text-decoration: none;
background: transparent;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 6px 10px;
line-height: 1.2;
transition: background 160ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 160ms;
}
a.button:hover { background: #2b3040; }
a.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
button.icon { padding: 6px 9px; line-height: 1; }
button.danger { color: #f0b6b0; border-color: #5a2b28; }
button.danger:hover { background: #3a1f1d; border-color: var(--danger); }
.row-actions { display: flex; gap: 6px; }
.add-row { margin-top: var(--sp-2); }
/* ---- repeating rows (feeds / rotation / messages) ---- */
.list { display: flex; flex-direction: column; gap: var(--sp-1); }
.item {
display: grid;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-1);
background: var(--ground);
border: 1px solid var(--line);
border-radius: 8px;
}
.item .grip {
font-family: var(--mono);
color: var(--ink-dim);
font-size: 12px;
text-align: center;
user-select: none;
}
.feed-item { grid-template-columns: 90px 110px minmax(0, 1fr) auto; }
.rot-item { grid-template-columns: 28px 150px minmax(0, 1fr) auto; }
.msg-item { grid-template-columns: minmax(0, 1fr) 130px auto; }
@media (max-width: 560px) {
.feed-item, .rot-item, .msg-item { grid-template-columns: minmax(0, 1fr); }
.item .row-actions { justify-content: flex-end; }
}
.empty {
color: var(--ink-dim);
font-size: 13.5px;
padding: var(--sp-2);
border: 1px dashed var(--line-strong);
border-radius: 8px;
text-align: center;
}
/* ---- chips ---- */
.chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--ink-dim);
background: var(--ground);
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 6px 12px 6px 10px;
cursor: pointer;
transition: border-color 160ms, color 160ms;
}
.chip .sw { width: 12px; height: 12px; border-radius: 3px; border: 1px solid rgba(255,255,255,0.18); }
.chip:hover { border-color: #4a5268; }
.chip[aria-pressed="true"] { color: var(--ink); border-color: var(--accent); }
.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- save bar ---- */
.savebar {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 6;
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
background: var(--panel);
border-top: 1px solid var(--line-strong);
}
.savebar .status { flex: 1; min-width: 0; font-size: 13.5px; color: var(--ink-dim); }
.savebar .status.ok { color: var(--ok); }
.savebar .status.err { color: #f0938a; }
.savebar .status.warn { color: var(--warn); }
.warnings {
list-style: none;
margin: var(--sp-1) 0 0;
padding: 0;
font-size: 12.5px;
color: var(--warn);
}
.warnings li::before { content: "! "; }
/* Channels: same row language as the other lists, plus the schedule
sub-editor and the week grid. */
.ch-item { flex-wrap: wrap; }
.ch-item .ch-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); width: 100%; }
.ch-item .ch-head .toggle { flex-shrink: 0; white-space: nowrap; }
.ch-item input.ch-number { width: 5.5em; flex: 0 0 auto; }
/* The name takes the slack on a wide screen and shrinks to fit a phone;
width: auto so the input's own 100% never forces it onto its own line. */
.ch-item input.ch-name { flex: 1 1 8em; min-width: 6em; width: auto; }
.ch-item .ch-head select { width: auto; flex: 0 0 auto; }
.ch-item .ch-head .row-actions { margin-left: auto; }
.ch-body { width: 100%; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; padding-left: 2.4em; }
@media (max-width: 560px) { .ch-body, .ch-windows { padding-left: 0; } }
.ch-body .note { opacity: 0.75; }
/* The body reads as one line ("Breaks from [folder] every [15] min of
program, [3] spots per break"), so its controls size to their content
and wrap on a narrow screen instead of stacking full-width. */
.ch-body select { width: auto; max-width: 100%; }
.ch-body input.ch-title { width: 12em; }
.ch-phrase { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.ch-breaks input[type="number"] { width: 4em; }
.ch-windows { width: 100%; padding-left: 2.4em; display: flex; flex-direction: column; gap: var(--sp-1); }
.ch-window { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.ch-window input[type="time"] { width: 8em; }
.day-chip {
font: inherit; padding: 2px 7px; border-radius: 999px; cursor: pointer;
background: transparent; color: var(--ink-dim); border: 1px solid #3a4258;
}
.day-chip[aria-pressed="true"] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
#week-grid {
display: grid; grid-template-columns: 2.5em repeat(7, 1fr); gap: 2px;
margin-top: 6px; font-size: 0.85em;
}
#week-grid .wg-head { text-align: center; opacity: 0.8; padding: 2px 0; }
#week-grid .wg-hours { position: relative; }
#week-grid .wg-day { position: relative; height: 192px; background: rgba(255,255,255,0.04); border-radius: 4px; }
#week-grid .wg-block {
position: absolute; left: 2px; right: 2px; border-radius: 3px;
background: var(--accent); color: #fff; overflow: hidden;
font-size: 0.9em; line-height: 1.2; padding: 1px 4px; min-height: 10px;
}
#week-grid .wg-hour { position: absolute; right: 4px; transform: translateY(-50%); opacity: 0.6; }
</style>
</head>
<body>
<header class="masthead">
<div class="masthead-row">
<div class="brand">
<span class="bug">CABLE 82</span>
<span class="sub">Control Room</span>
<a id="version" class="version" href="https://github.com/nothans/cable-82" target="_blank" rel="noopener">…</a>
</div>
<div class="actions">
<a class="button" href="/" target="_blank" rel="noopener">View channel ↗</a>
<a class="button" href="/remote-control" target="_blank" rel="noopener">Remote control ↗</a>
</div>
</div>
<nav class="groupnav" aria-label="Control room sections">
<a href="#quick">Quick settings</a>
<a href="#channels">Channels</a>
<a href="#board">Community Board</a>
<a href="#preview">Channel Preview</a>
<a href="#jarvis">Jarvis</a>
<a href="#display">Display</a>
<a href="#server">Server</a>
</nav>
</header>
<main id="app" aria-busy="true">
<section class="group" id="quick" aria-labelledby="g-quick">
<h2 class="group-head" id="g-quick">Quick settings</h2>
<p class="group-lede">The things a station touches week to week: who it is, what the board is saying, and the music behind it. Everything else has its own group below.</p>
<section class="panel" id="p-identity">
<h3>Identity</h3>
<p class="hint">The name and tagline across the top of the board. The name is the station's everywhere it appears; the crawl falls back to both when no feed has anything to say.</p>
<div class="grid">
<div class="field">
<label for="f-channelName">Station name</label>
<input type="text" id="f-channelName" maxlength="40" placeholder="CABLE 82">
</div>
<div class="field">
<label for="f-tagline">Tagline</label>
<input type="text" id="f-tagline" maxlength="60" placeholder="COMMUNITY BULLETIN BOARD">
</div>
</div>
</section>
<section class="panel" id="p-messages">
<h3>Community messages</h3>
<p class="hint">Your bulletin board: birthdays, bake sales, lost dogs. Leave the color on Auto to let them cycle.</p>
<div id="messages" class="list"></div>
<button type="button" id="addMessage" class="small add-row">+ Add message</button>
</section>
<section class="panel" id="p-music">
<h3>Music</h3>
<p class="hint">Continuous background music behind the Community Board, played from the <code>music/</code> folder - drop audio files there and they join the rotation. On a Pi kiosk, launch Chromium with <code>--autoplay-policy=no-user-gesture-required</code> so it plays from boot; on a desktop it starts on your first click.</p>
<div class="grid">
<div class="field">
<label>Playback</label>
<div class="toggle"><input type="checkbox" id="f-musicEnabled"><label for="f-musicEnabled">Play background music</label></div>
<div class="toggle"><input type="checkbox" id="f-musicShuffle"><label for="f-musicShuffle">Shuffle the tracks</label></div>
</div>
<div class="field">
<label for="f-musicVolume">Volume</label>
<div class="range-row">
<input type="range" id="f-musicVolume" min="0" max="100" step="1">
<output id="musicVolumeOut">60%</output>
</div>
</div>
</div>
<div class="field" style="margin-top: var(--sp-2);">
<label>Tracks in the folder <span id="trackCount" class="count"></span></label>
<div id="trackList" class="track-list"></div>
</div>
</section>
</section>
<section class="group" id="channels" aria-labelledby="g-channels">
<h2 class="group-head" id="g-channels">Channels</h2>
<section class="panel" id="p-dial">
<h3>The dial</h3>
<p class="hint">Ordered by channel number. The bulletin board is a channel; a video channel plays a folder on the broadcast clock, with optional commercial breaks from a second folder; a guide channel lists what is on every channel on the dial; an external channel frames another page (a retro weather app).</p>
<div id="channelList" class="list"></div>
<button type="button" id="addChannel" class="small add-row">+ Add channel</button>
<div id="week-grid-wrap" hidden>
<p class="hint" style="margin-top:12px">The week as the schedule reads it: blocks are on-air windows.</p>
<div id="week-grid"></div>
</div>
</section>
<section class="panel" id="p-tuning">
<h3>Tuning</h3>
<p class="hint">How the dial is turned: arrow keys and digits, a gamepad or joystick, or <code>POST /api/tune</code> from anything that can make a request. The <a href="/remote-control" target="_blank" rel="noopener">remote control</a> uses the HTTP bus.</p>
<div class="grid">
<div class="field">
<div class="toggle"><input type="checkbox" id="f-tunerKeyboard"><label for="f-tunerKeyboard">Keyboard tuning (arrows, digits)</label></div>
<div class="toggle"><input type="checkbox" id="f-tunerGamepad"><label for="f-tunerGamepad">Gamepad tuning (stick or d-pad, buttons)</label></div>
</div>
<div class="field">
<div class="toggle"><input type="checkbox" id="f-tunerHttp"><label for="f-tunerHttp">HTTP tuning (/api/tune)</label></div>
<div class="toggle"><input type="checkbox" id="f-tunerWrap"><label for="f-tunerWrap">Dial wraps at the ends</label></div>
</div>
<div class="field">
<label for="f-tunerCut">Channel change</label>
<select id="f-tunerCut">
<option value="static">Tuner static (RF tuner, the default)</option>
<option value="black">Black screen (a later cable box)</option>
<option value="none">Hard cut (no cover)</option>
</select>
<span class="note">Static and black also give a video channel a beat to load; a hard cut can flash blank.</span>
</div>
<div class="field">
<label for="f-tunerPower">Power off</label>
<select id="f-tunerPower">
<option value="crt">Collapse to a line (a tube losing power)</option>
<option value="black">Straight to black (a flat panel)</option>
</select>
<span class="note">The picture folds into one bright line, snaps to a dot, and the phosphor fades. Coming back, it blooms open.</span>
</div>
<div class="field">
<label for="f-tunerShutdown">Presses to shut down</label>
<input type="number" id="f-tunerShutdown" min="0" max="20" step="1">
<span class="note">A gamepad button pressed this many times in a row shuts the machine down. Works where the Shut down button does. 5 to 20; 0 turns the count off. Two presses are the power key.</span>
</div>
</div>
</section>
</section>
<section class="group" id="board" aria-labelledby="g-board">
<h2 class="group-head" id="g-board">Community Board</h2>
<p class="group-lede">Everything on channel 82 beyond its name and messages: the page lineup, the colors, the decks of facts and jokes, the weather, the feeds, the crawl, CheerLights.</p>
<section class="panel" id="p-rotation">
<h3>Page rotation</h3>
<p class="hint">The board's lineup, in order, and how long each page holds. Each visit to a headlines slot shows the next item from that feed.</p>
<div id="rotation" class="list"></div>
<button type="button" id="addSlot" class="small add-row">+ Add page</button>
<div class="grid" style="margin-top: var(--sp-2);">
<div class="field">
<label for="f-pageSeconds">Seconds per page</label>
<input type="number" id="f-pageSeconds" min="3" max="120" step="1">
</div>
</div>
</section>
<section class="panel" id="p-colors">
<h3>Colors</h3>
<p class="hint">Pages with no fixed color cycle through the palette you pick.</p>
<div class="field">
<label>Page cycle</label>
<div id="pageCycle" class="chips"></div>
</div>
<div class="grid" style="margin-top: var(--sp-2);">
<div class="field">
<label for="f-headerBg">Header background</label>
<select id="f-headerBg"></select>
</div>
<div class="field">
<label for="f-crawlBg">Crawl background</label>
<select id="f-crawlBg"></select>
</div>
</div>
</section>
<section class="panel" id="p-facts">
<h3>Facts & jokes</h3>
<p class="hint">Two decks of one-liners, one per line. The rotation shows one at a time - Facts under a DID YOU KNOW card, dad jokes under a DAD JOKE card. Add either to the rotation above.</p>
<div class="grid">
<div class="field">
<label for="f-facts">Fun facts <span id="factsCount" class="count"></span></label>
<textarea id="f-facts" spellcheck="true" placeholder="Honey never spoils"></textarea>
</div>
<div class="field">
<label for="f-dadJokes">Dad jokes <span id="jokesCount" class="count"></span></label>
<textarea id="f-dadJokes" spellcheck="true" placeholder="Why did the scarecrow win an award? He was outstanding in his field."></textarea>
</div>
</div>
</section>
<section class="panel" id="p-weather">
<h3>Weather</h3>
<p class="hint">One weather strip, fed by Open-Meteo (free, no account): current conditions, the day's high and low, wind, sunrise and sunset. Search your town, pick the match, choose units. Add a Weather page to the rotation to put it on the air.</p>
<div class="field">
<label>Location</label>
<div id="wxCurrent" class="wx-current"></div>
<div class="wx-search">
<input type="text" id="wxSearch" placeholder="Search a town, e.g. Boston">
<button type="button" id="wxLookup" class="small">Look up</button>
</div>
<div id="wxResults" class="wx-results"></div>
</div>
<div class="grid" style="margin-top: var(--sp-2);">
<div class="field">
<label for="f-tempUnit">Temperature</label>
<select id="f-tempUnit">
<option value="F">Fahrenheit (°F)</option>
<option value="C">Celsius (°C)</option>
</select>
</div>
<div class="field">
<label for="f-windUnit">Wind speed</label>
<select id="f-windUnit">
<option value="mph">Miles per hour (mph)</option>
<option value="kmh">Kilometers per hour (km/h)</option>
</select>
</div>
<div class="field">
<label for="f-wxTimezone">Timezone</label>
<input type="text" id="f-wxTimezone" class="mono" placeholder="auto">
<span class="note">Auto-filled from the location; change only if needed.</span>
</div>
</div>
</section>
<section class="panel" id="p-feeds">
<h3>Feeds</h3>
<p class="hint">RSS or Atom sources for the headlines pages and the crawl. The <code>id</code> is how the rotation and crawl refer to a feed; the label is what viewers see. The server only ever fetches URLs listed here.</p>
<div id="feeds" class="list"></div>
<button type="button" id="addFeed" class="small add-row">+ Add feed</button>
<div class="grid" style="margin-top: var(--sp-2);">
<div class="field">
<label for="f-refreshMinutes">Feed refresh (minutes)</label>
<input type="number" id="f-refreshMinutes" min="1" max="1440" step="1">
</div>
<div class="field">
<label for="f-maxItemsPerFeed">Items kept per feed</label>
<input type="number" id="f-maxItemsPerFeed" min="1" max="100" step="1">
</div>
</div>
</section>
<section class="panel" id="p-crawl">
<h3>The crawl</h3>
<p class="hint">The ticker along the bottom, and the fixed label pinned to its left.</p>
<div class="grid">
<div class="field">
<label for="f-crawlFlag">Flag label</label>
<input type="text" id="f-crawlFlag" maxlength="16" placeholder="LATEST">
<span class="note">Leave blank to hide the flag.</span>
</div>
<div class="field">
<label for="f-crawlSeconds">Seconds to cross the screen</label>
<input type="number" id="f-crawlSeconds" min="2" max="60" step="1">
</div>
<div class="field">
<label for="f-crawlSeparator">Separator between items</label>
<input type="text" id="f-crawlSeparator" class="mono" maxlength="16">
</div>
</div>
<div class="field" style="margin-top: var(--sp-2);">
<label>Feeds that ride the ticker</label>
<div id="crawlFeeds" class="chips"></div>
</div>
</section>
<section class="panel" id="p-cheerlights">
<h3>CheerLights</h3>
<p class="hint"><a href="https://cheerlights.com" target="_blank" rel="noopener">CheerLights</a> is one global color anyone in the world can set. When this is on, the crawl carries the latest color as a ticker item, checked about once a minute. Write <code>{color}</code> where the color name should land.</p>
<div class="grid">
<div class="field">
<label>Ticker</label>
<div class="toggle"><input type="checkbox" id="f-cheerEnabled"><label for="f-cheerEnabled">Show the CheerLights color in the crawl</label></div>
</div>
<div class="field">
<label for="f-cheerTemplate">Message</label>
<input type="text" id="f-cheerTemplate" maxlength="120" placeholder="THE WORLD IS SET TO {COLOR}">
<span class="note">Or: "Right now, the CheerLights color is {color}".</span>
</div>
</div>
</section>
</section>
<section class="group" id="preview" aria-labelledby="g-preview">
<h2 class="group-head" id="g-preview">Channel Preview</h2>
<section class="panel" id="p-preview-identity">
<h3>Identity</h3>
<p class="hint">The name and tagline across the top of the guide.</p>
<div class="grid">
<div class="field">
<label for="f-previewName">Channel name</label>
<input type="text" id="f-previewName" maxlength="40" placeholder="CABLEVUE">
<span class="note">Also names channel 0 on the dial.</span>
</div>
<div class="field">
<label for="f-previewTagline">Tagline</label>
<input type="text" id="f-previewTagline" maxlength="60" placeholder="WHAT'S ON, AND WHAT'S NEXT">
</div>
</div>
</section>
<section class="panel" id="p-preview-grid">
<h3>The grid</h3>
<p class="hint">Every channel on the dial is listed. What a video channel's programs are called is set on the channel itself, in the dial.</p>
<div class="grid">
<div class="field">
<label for="f-previewSlots">Half hours across</label>
<input type="number" id="f-previewSlots" min="2" max="4" step="1">
<span class="note">How far ahead the grid looks: three columns is an hour and a half.</span>
</div>
<div class="field">
<label for="f-previewScroll">Seconds per screenful</label>
<input type="number" id="f-previewScroll" min="4" max="120" step="1">
<span class="note">How long the lineup takes to crawl one screen. It only crawls when it is taller than the screen.</span>
</div>
<div class="field">
<label>Clock</label>
<div class="toggle"><input type="checkbox" id="f-previewSeconds"><label for="f-previewSeconds">Count seconds</label></div>
<span class="note">The AM/PM vs 24-hour choice follows the Clock panel under Display.</span>
</div>
</div>
</section>
<section class="panel" id="p-preview-colors">
<h3>Colors</h3>
<p class="hint">The background the grid is drawn on.</p>
<div class="grid">
<div class="field">
<label for="f-previewBg">Background</label>
<select id="f-previewBg"></select>
</div>
</div>
</section>
</section>
<section class="group" id="jarvis" aria-labelledby="g-jarvis">
<h2 class="group-head" id="g-jarvis">Jarvis</h2>
<p class="group-lede">The station's on-screen host. Anything that can send a web request can put him on the set to say a line or answer a question: the weather, the CheerLights color, the time, a joke. See <a href="https://github.com/nothans/cable-82/blob/main/docs/api.md#jarvis" target="_blank" rel="noopener">the API</a>.</p>
<section class="panel" id="p-jarvis-host">
<h3>Host</h3>
<div class="grid">
<div class="field">
<label>On the air</label>
<div class="toggle"><input type="checkbox" id="f-jarvisEnabled"><label for="f-jarvisEnabled">Let Jarvis pop up over the channel</label></div>
</div>
<div class="field">
<label for="f-jarvisName">Name on his caption</label>
<input type="text" id="f-jarvisName" maxlength="16" placeholder="JARVIS">
</div>
</div>
</section>
<section class="panel" id="p-jarvis-voice">
<h3>Voice</h3>
<p class="hint">He speaks through espeak-ng when the machine has it (<code>sudo apt install espeak-ng</code> on a Pi). Without it he still appears and talks with a drawn mouth. <span id="jarvisVoiceStatus" class="note"></span></p>
<div class="grid">
<div class="field">
<label>Speech</label>
<div class="toggle"><input type="checkbox" id="f-jarvisVoice"><label for="f-jarvisVoice">Speak his lines out loud</label></div>
</div>
<div class="field">
<label for="f-jarvisEspeakVoice">Voice</label>
<select id="f-jarvisEspeakVoice">
<option value="en-us+m3">American, male (the default)</option>
<option value="en-us+m1">American, male, lower</option>
<option value="en-us+m7">American, male, rougher</option>
<option value="en-us+f3">American, female</option>
<option value="en+m3">British, male</option>
<option value="en+f3">British, female</option>
<option value="en-us+whisper">Whisper</option>
</select>
</div>
<div class="field">
<label for="f-jarvisSpeed">Speed</label>
<div class="range-row">
<input type="range" id="f-jarvisSpeed" min="80" max="260" step="5">
<output id="jarvisSpeedOut">150</output>
</div>
<span class="note">Words a minute.</span>
</div>
<div class="field">
<label for="f-jarvisPitch">Pitch</label>
<div class="range-row">
<input type="range" id="f-jarvisPitch" min="0" max="99" step="1">
<output id="jarvisPitchOut">38</output>
</div>
</div>
</div>
</section>
<section class="panel" id="p-jarvis-try">
<h3>Try him</h3>
<p class="hint">Sends to every set that is listening, with the settings last saved. Save first to hear a change.</p>
<div class="field">
<label for="f-jarvisSay">A line to say</label>
<input type="text" id="f-jarvisSay" maxlength="240" placeholder="Good evening, and welcome to CABLE 82.">
</div>
<div class="jarvis-try">
<button type="button" class="small primary" id="jarvisSayBtn">Say it</button>
<button type="button" class="small" data-ask="weather">Weather</button>
<button type="button" class="small" data-ask="cheerlights">CheerLights</button>
<button type="button" class="small" data-ask="time">Time</button>
<button type="button" class="small" data-ask="joke">Joke</button>
<span id="jarvisTryOut" class="note" role="status"></span>
</div>
</section>
</section>
<section class="group" id="display" aria-labelledby="g-display">
<h2 class="group-head" id="g-display">Display</h2>
<section class="panel" id="p-picture">
<h3>Picture</h3>
<p class="hint">For a real tube fed over composite or an RF modulator. Leave it off on a flat panel.</p>
<div class="grid">
<div class="field">
<label>Tube</label>
<div class="toggle"><input type="checkbox" id="f-crtMode"><label for="f-crtMode">CRT mode: softer colors, no drop shadow</label></div>
<span class="note">Calms the saturated reds and blues that composite and RF smear.</span>
<div class="toggle"><input type="checkbox" id="f-crtInkText"><label for="f-crtInkText">Dark text on color pages</label></div>
<span class="note">For tubes that smear white text. Only applies while CRT mode is on.</span>
</div>
<div class="field">
<label for="f-textScale">Text size</label>
<input type="number" id="f-textScale" min="1" max="1.5" step="0.05">
<span class="note">1 is the original layout. Try 1.25 on a small tube.</span>
</div>
<div class="field">
<label for="f-overscanX">Overscan margin, sides (%)</label>
<input type="number" id="f-overscanX" min="0" max="15" step="1">
<span class="note">Raise it if your TV crops the left or right edge.</span>
</div>
<div class="field">
<label for="f-overscanY">Overscan margin, top and bottom (%)</label>
<input type="number" id="f-overscanY" min="0" max="15" step="1">
<span class="note">Raise it if your TV crops the top or bottom edge.</span>
</div>
</div>
</section>
<section class="panel" id="p-faux">
<h3>Faux CRT</h3>
<p class="hint">For a flat panel. The picture plays inside a drawn wood console that fills the screen, and the tells of a tube are the sliders below. Turned off on save while CRT mode is on.</p>
<div class="grid">
<div class="field">
<label>Set</label>
<div class="toggle"><input type="checkbox" id="f-fauxOn"><label for="f-fauxOn">Faux CRT</label></div>
<select id="f-fauxSet">
<option value="console">Wood console with a channel dial</option>
<option value="none">Glass only</option>
</select>
<span class="note">The dial shows the channel, turns on every tune, and tunes when you click a number.</span>
</div>
<div class="field">
<label for="f-fauxCurve">Curve</label>
<div class="range-row"><input type="range" id="f-fauxCurve" min="0" max="10" step="1"><output id="o-fauxCurve">3</output></div>
<span class="note">The bulge of the glass, picture included; 0 is flat.</span>
</div>
<div class="field">
<label for="f-fauxScanlines">Scanlines</label>
<div class="range-row"><input type="range" id="f-fauxScanlines" min="0" max="10" step="1"><output id="o-fauxScanlines">4</output></div>
<span class="note">480 lines, the count the picture is drawn at.</span>
</div>
<div class="field">
<label for="f-fauxNoise">Noise</label>
<div class="range-row"><input type="range" id="f-fauxNoise" min="0" max="10" step="1"><output id="o-fauxNoise">2</output></div>
<span class="note">Grain over the picture.</span>
</div>
<div class="field">
<label for="f-fauxWave">Wave</label>
<div class="range-row"><input type="range" id="f-fauxWave" min="0" max="10" step="1"><output id="o-fauxWave">1</output></div>
<span class="note">A slow wobble of the raster, a weak signal on rabbit ears.</span>
</div>
<div class="field">
<label for="f-fauxBloom">Bloom</label>
<div class="range-row"><input type="range" id="f-fauxBloom" min="0" max="10" step="1"><output id="o-fauxBloom">2</output></div>
<span class="note">Bright areas bleed into their neighbors.</span>
</div>
<div class="field">
<label for="f-fauxVignette">Vignette</label>
<div class="range-row"><input type="range" id="f-fauxVignette" min="0" max="10" step="1"><output id="o-fauxVignette">3</output></div>
<span class="note">The corners of the glass go dark.</span>
</div>
<div class="field">
<label for="f-fauxFlicker">Flicker</label>
<div class="range-row"><input type="range" id="f-fauxFlicker" min="0" max="10" step="1"><output id="o-fauxFlicker">0</output></div>
<span class="note">A slight breathing of the brightness; it tires eyes.</span>
</div>
<div class="field">
<label>Extras</label>
<div class="toggle"><input type="checkbox" id="f-fauxMask"><label for="f-fauxMask">Phosphor mask</label></div>
<span class="note">A faint RGB triad, visible on a large panel and lost on a small one.</span>
<div class="toggle"><input type="checkbox" id="f-fauxReflection"><label for="f-fauxReflection">Reflection</label></div>
<span class="note">A soft highlight on the glass.</span>
</div>
</div>
</section>
<section class="panel" id="p-clock">
<h3>Clock</h3>
<p class="hint">How the set writes the time, everywhere it shows one.</p>
<div class="grid">
<div class="field">
<label for="f-timeFormat">Clock format</label>
<select id="f-timeFormat">
<option value="12h">12-hour (8:04 PM)</option>
<option value="24h">24-hour (20:04)</option>
</select>
</div>
</div>
</section>
<section class="panel" id="p-kiosk">
<h3>Kiosk</h3>
<p class="hint">The hour the display reloads itself. Pick a quiet one.</p>
<div class="grid">
<div class="field">
<label for="f-dailyReloadHour">Daily reload hour</label>
<select id="f-dailyReloadHour"></select>
</div>
</div>
</section>
</section>
<section class="group" id="server" aria-labelledby="g-server">
<h2 class="group-head" id="g-server">Server</h2>
<section class="panel" id="p-vitals">
<h3>Vitals</h3>
<p class="hint">How the machine the station runs on is doing, read every ten seconds while this page is open. The same numbers are at <code>GET /api/vitals</code>.</p>
<div class="vitals" id="vitals"></div>
<p class="vitals-note" id="vitalsNote"></p>
</section>
<section class="panel" id="p-network">
<h3>Network</h3>
<p class="hint">The display, this control room, and the remote all live on this port.</p>
<div class="grid">
<div class="field">
<label for="f-port">Server port</label>
<input type="number" id="f-port" min="1" max="65535" step="1">
<span class="note">Takes effect the next time the server restarts.</span>
</div>
</div>
</section>
<section class="panel" id="p-power" hidden>
<h3>Power</h3>
<p class="hint">Restart or switch off the machine the station runs on. Both stop the station and flush the disks first, which is what keeps a memory card from being corrupted.</p>
<div class="grid">
<div class="field">
<label>Restart</label>
<div class="power-row">
<button type="button" id="restartHost" class="ghost small">Restart…</button>
<span class="note" id="restartNote">Back on the air in about a minute.</span>
</div>
</div>
<div class="field">
<label>Shut down</label>
<div class="power-row">
<button type="button" id="shutdownHost" class="ghost small">Shut down…</button>
<span class="note" id="shutdownNote">Wait for the green light to stop blinking, then pull the power.</span>
</div>
</div>
</div>
</section>
<section class="panel" id="p-release">
<h3>Release</h3>
<p class="hint">What this install is running.</p>
<div class="grid">
<div class="field">
<label>Version</label>
<div id="releaseVersion" class="release-line mono">…</div>
<span class="note" id="releaseNote"></span>
</div>
<div class="field">
<label>Source</label>
<div class="release-line"><a id="releaseRepo" href="https://github.com/nothans/cable-82" target="_blank" rel="noopener">github.com/nothans/cable-82</a></div>
<span class="note">Update with <code>git pull</code> and a restart; the display reloads itself.</span>
</div>
</div>
</section>
</section>
</main>
<div class="savebar">
<div class="status" id="status">Loading current channel…</div>
<button type="button" id="revert" class="ghost small">Revert</button>
<button type="button" id="save" class="primary" disabled>Save changes</button>
</div>