Files
Rene KievitsandClaude Opus 5 7529a60650 Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.

  native/       the service: capture backends (PulseAudio, ALSA, exec,
                test tone, all dlopen-based), DSP, and one file per sink
  frontend/     D-pad driven UI at a fixed 1920x1080
  servicefiles/ native service manifest plus the boot script
  host/         RTP receiver and the loopback installer for the HyperHDR
                machine
  tools/        build/package, asset generation, Homebrew Channel
                manifest, on-TV probe
  test/         host-side suites: FlatBuffers and RTP verified against
                real decoders, the engine end to end, the page in jsdom

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:21:00 +02:00

515 lines
8.3 KiB
CSS

/* Designed against a 1920x1080 viewport, which is what webOS reports to an app
* on both a 1080p and a 4K panel. Sizes are absolute px for that reason.
*
* Two rules shape everything here: text is read from the sofa, so nothing is
* smaller than 22px; and focus is the only cursor, so the focused element has
* to be unmistakable across the room. */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0b0e13;
color: #e8ecf3;
font-family: "LG Smart UI", "Museo Sans", "Helvetica Neue", Arial, sans-serif;
font-size: 26px;
line-height: 1.4;
/* The remote is the pointer. A caret blinking somewhere off-screen is only
* ever confusing. */
-webkit-user-select: none;
user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
/* Overscan safe area: older sets crop up to 5% of each edge. */
padding: 40px 60px 30px;
}
/* --- header --------------------------------------------------------------- */
#header {
display: flex;
align-items: center;
flex: none;
gap: 30px;
padding-bottom: 22px;
border-bottom: 2px solid #1d2430;
}
.brand h1 {
margin: 0;
font-size: 46px;
font-weight: 300;
letter-spacing: 0.5px;
}
.subtitle {
color: #7c8798;
font-size: 22px;
}
.header-status {
display: flex;
align-items: center;
gap: 18px;
margin-left: auto;
}
.pill {
display: inline-block;
padding: 8px 24px;
border-radius: 999px;
background: #1d2430;
color: #9aa6b8;
font-size: 24px;
white-space: nowrap;
}
.pill.running {
background: #123a24;
color: #57d98a;
}
.pill.starting {
background: #3a3212;
color: #e2c657;
}
.pill.error {
background: #3d1a1c;
color: #ff7b7b;
}
.uptime {
color: #7c8798;
font-size: 22px;
min-width: 110px;
}
/* --- tabs ----------------------------------------------------------------- */
#tabs {
display: flex;
flex: none;
gap: 14px;
padding: 20px 0;
}
.tab {
padding: 12px 34px;
border: 2px solid transparent;
border-radius: 12px;
background: #151b25;
color: #9aa6b8;
font: inherit;
cursor: default;
}
.tab.active {
background: #1e2a3d;
color: #ffffff;
}
/* --- content -------------------------------------------------------------- */
#content {
flex: 1;
overflow-y: auto;
padding-right: 14px;
/* Room to scroll the last card clear of the bottom edge. */
padding-bottom: 120px;
}
#content::-webkit-scrollbar {
width: 10px;
}
#content::-webkit-scrollbar-thumb {
border-radius: 5px;
background: #2a3444;
}
.panel {
display: flex;
flex-direction: column;
gap: 22px;
}
.card {
padding: 26px 32px;
border: 2px solid #1d2430;
border-radius: 16px;
background: #121722;
}
.card h2 {
margin: 0 0 6px;
color: #cfd8e6;
font-size: 30px;
font-weight: 400;
}
.card.error {
border-color: #5a2226;
background: #1c1215;
color: #ff9d9d;
}
.blurb {
margin: 0 0 14px;
max-width: 1200px;
color: #7c8798;
font-size: 22px;
}
.muted {
color: #7c8798;
}
.hidden {
display: none !important;
}
/* --- fields --------------------------------------------------------------- */
.field {
display: flex;
align-items: center;
gap: 30px;
padding: 14px 0;
border-top: 1px solid #1a212c;
}
.field:first-child {
border-top: none;
}
.field-text {
flex: 1;
min-width: 0;
}
.field-label {
font-size: 26px;
}
.field-hint {
color: #7c8798;
font-size: 21px;
}
.field-control {
flex: none;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 16px;
}
/* --- controls ------------------------------------------------------------- */
.btn {
min-width: 150px;
padding: 12px 30px;
border: 2px solid #2c3646;
border-radius: 12px;
background: #1c2432;
color: #e8ecf3;
font: inherit;
text-align: center;
cursor: default;
}
.btn.primary {
background: #1f4d76;
border-color: #2b6ba6;
}
.btn.danger {
border-color: #5a2226;
background: #2a171a;
color: #ff9d9d;
}
.btn.toggle {
min-width: 120px;
}
.btn.toggle.on {
border-color: #2f7a4d;
background: #143324;
color: #6ee29a;
}
.btn.choice {
min-width: 300px;
}
.input {
width: 360px;
padding: 12px 18px;
border: 2px solid #2c3646;
border-radius: 12px;
background: #0d1119;
color: #e8ecf3;
font: inherit;
/* Text fields are the one place typing happens. */
-webkit-user-select: text;
user-select: text;
}
.input.wide {
width: 620px;
}
/* One focus treatment for every control: a bright ring plus a lift. Visible
* from across the room on both an OLED and a dim LCD. */
.focusable:focus {
outline: none;
border-color: #4aa3ff;
box-shadow: 0 0 0 4px rgba(74, 163, 255, 0.35);
}
.btn.focusable:focus,
.tab.focusable:focus {
background: #2b6ba6;
color: #ffffff;
}
.btn.toggle.on.focusable:focus {
background: #2f7a4d;
color: #ffffff;
}
.btn.danger.focusable:focus {
background: #8a2f36;
color: #ffffff;
}
/* --- meter ---------------------------------------------------------------- */
.meter {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 0 18px;
}
.meter-row {
display: flex;
align-items: center;
gap: 20px;
}
.meter-label {
width: 90px;
color: #7c8798;
font-size: 22px;
}
.meter-db {
width: 130px;
color: #9aa6b8;
font-size: 22px;
text-align: right;
/* dB numbers jitter constantly; tabular digits stop the label dancing. */
font-variant-numeric: tabular-nums;
}
.bar {
flex: 1;
height: 26px;
overflow: hidden;
border-radius: 13px;
background: #0d1119;
}
.bar-fill {
width: 0;
height: 100%;
border-radius: 13px;
background: linear-gradient(90deg, #3aa76d, #7bd44f 70%, #e2c657 88%, #ff5f5f);
/* Short enough to feel live at the service's 100 ms notify interval. */
transition: width 80ms linear;
}
.bar.rms .bar-fill {
background: linear-gradient(90deg, #2b6ba6, #4aa3ff);
}
.bands {
display: flex;
align-items: flex-end;
gap: 8px;
height: 180px;
padding: 12px 0 0;
border-top: 1px solid #1a212c;
}
.band {
flex: 1;
min-height: 3px;
border-radius: 5px 5px 2px 2px;
background: linear-gradient(180deg, #4aa3ff, #1f4d76);
transition: height 80ms linear;
}
.clip {
margin-top: 14px;
color: #ff7b7b;
font-size: 24px;
}
/* --- info grid ------------------------------------------------------------ */
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18px 30px;
padding-top: 12px;
}
.info-item {
min-width: 0;
}
.info-key {
color: #7c8798;
font-size: 21px;
}
.info-value {
overflow: hidden;
font-size: 27px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- sink status ---------------------------------------------------------- */
.sink-status {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 12px;
}
.sink-line {
display: flex;
align-items: baseline;
gap: 18px;
}
.dot {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4b5666;
}
.dot.ok {
background: #57d98a;
}
.dot.bad {
background: #ff5f5f;
}
.sink-name {
min-width: 320px;
}
.sink-detail {
color: #7c8798;
font-size: 22px;
}
/* --- sink cards ----------------------------------------------------------- */
#sink-cards {
display: flex;
flex-direction: column;
gap: 22px;
}
.sink-card .sink-head {
display: flex;
align-items: center;
gap: 30px;
}
.sink-card .sink-head h2 {
margin: 0;
}
.sink-card .sink-head .field-control {
margin-left: auto;
}
.sink-card.off .sink-body {
opacity: 0.45;
}
.badge {
padding: 4px 16px;
border-radius: 999px;
background: #1f4d76;
color: #9fd0ff;
font-size: 20px;
}
/* --- output / logs -------------------------------------------------------- */
.output {
max-height: 520px;
margin: 18px 0 0;
padding: 20px;
overflow: auto;
border-radius: 12px;
background: #0d1119;
color: #b8c4d4;
font-family: "Courier New", monospace;
font-size: 20px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
-webkit-user-select: text;
user-select: text;
}
/* --- toast ---------------------------------------------------------------- */
.toast {
position: fixed;
left: 50%;
bottom: 48px;
z-index: 10;
padding: 16px 40px;
transform: translateX(-50%);
border: 2px solid #2c3646;
border-radius: 14px;
background: #1c2432;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
font-size: 24px;
}
.toast.bad {
border-color: #5a2226;
background: #2a171a;
color: #ff9d9d;
}