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>
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
/* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user