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:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"id": "org.webosbrew.audiocap",
"version": "1.0.0",
"vendor": "Homebrew",
"type": "web",
"main": "index.html",
"title": "Audio Cap",
"appDescription": "Capture TV audio and stream it to HyperHDR and other receivers",
"icon": "assets/icon.png",
"largeIcon": "assets/largeIcon.png",
"iconColor": "#101820",
"splashBackground": "assets/splash.png",
"bgImage": "assets/splash.png",
"disableBackHistoryAPI": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+514
View File
@@ -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;
}
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1920, initial-scale=1">
<title>Audio Cap</title>
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div id="app">
<header id="header">
<div class="brand">
<h1>Audio Cap</h1>
<div class="subtitle">TV audio to HyperHDR and friends</div>
</div>
<div class="header-status">
<span id="state-pill" class="pill">Connecting</span>
<span id="uptime" class="uptime"></span>
</div>
<button id="power" type="button" class="focusable btn primary">Start</button>
</header>
<nav id="tabs">
<button type="button" class="focusable tab" data-panel="panel-status">Status</button>
<button type="button" class="focusable tab" data-panel="panel-sinks">Outputs</button>
<button type="button" class="focusable tab" data-panel="panel-capture">Capture</button>
<button type="button" class="focusable tab" data-panel="panel-system">System</button>
</nav>
<main id="content">
<section id="panel-status" class="panel">
<div class="card">
<h2>Level</h2>
<div class="meter">
<div class="meter-row">
<span class="meter-label">Peak</span>
<div id="meter-peak" class="bar peak"><div class="bar-fill"></div></div>
<span id="meter-peak-db" class="meter-db">—</span>
</div>
<div class="meter-row">
<span class="meter-label">RMS</span>
<div id="meter-rms" class="bar rms"><div class="bar-fill"></div></div>
<span id="meter-rms-db" class="meter-db">—</span>
</div>
</div>
<div id="bands" class="bands"></div>
<div id="clip" class="clip hidden">Clipping</div>
</div>
<div class="card">
<h2>Capture</h2>
<div id="capture-info" class="info-grid"></div>
</div>
<div class="card">
<h2>Outputs</h2>
<div id="sink-status" class="sink-status">
<div class="muted">Not running.</div>
</div>
</div>
<div id="error-card" class="card error hidden">
<h2>Error</h2>
<div id="error-text"></div>
</div>
</section>
<section id="panel-sinks" class="panel hidden">
<div id="sink-cards"></div>
</section>
<section id="panel-capture" class="panel hidden">
<div class="card">
<h2>Source</h2>
<div id="capture-fields"></div>
</div>
<div class="card">
<h2>Level follower</h2>
<p class="blurb">
How quickly the reported level rises and falls. Lower is faster.
</p>
<div id="dsp-fields"></div>
</div>
</section>
<section id="panel-system" class="panel hidden">
<div class="card">
<h2>Service</h2>
<div id="system-fields"></div>
</div>
<div class="card">
<h2>Diagnostics</h2>
<p class="blurb">
What the service can see on this TV: audio libraries, capture
devices and whether it is running as root.
</p>
<div class="actions">
<button id="run-diagnostics" type="button" class="focusable btn">Run diagnostics</button>
<button id="load-logs" type="button" class="focusable btn">Show log</button>
<button id="clear-logs" type="button" class="focusable btn">Clear log</button>
</div>
<pre id="output" class="output hidden"></pre>
</div>
<div class="card">
<h2>Settings file</h2>
<div id="config-path" class="info-grid"></div>
<div class="actions">
<button id="reset-config" type="button" class="focusable btn danger">Reset to defaults</button>
</div>
</div>
</section>
</main>
<div id="toast" class="toast hidden"></div>
</div>
<script src="js/mock.js"></script>
<script src="js/luna.js"></script>
<script src="js/nav.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+826
View File
@@ -0,0 +1,826 @@
// Wiring: load the settings, draw the panels, subscribe to the status feed.
//
// The service owns the settings; this file never keeps a second copy of the
// truth. Every edit goes out as a patch and the reply is what updates `state`.
(function (global) {
'use strict';
var SERVICE_ID = 'org.webosbrew.audiocap.service';
var SERVICE_DIR = '/media/developer/apps/usr/palm/services/' + SERVICE_ID;
var BOOT_SCRIPT = SERVICE_DIR + '/audiocapautostart';
var BOOT_LINK = '/var/lib/webosbrew/init.d/audiocapautostart';
var ELEVATE = '/media/developer/apps/usr/palm/services/'
+ 'org.webosbrew.hbchannel.service/elevate-service';
var state = {
settings: {},
status: null,
backends: [],
sinkDefs: [],
configPath: '',
persistent: true,
bootLinked: false,
diagnostics: null,
};
var statusSub = null;
var saveTimer = null;
var pendingPatch = null;
var toastTimer = null;
// --- small helpers --------------------------------------------------------
function $(id) {
return document.getElementById(id);
}
function merge(base, patch) {
Object.keys(patch).forEach(function (k) {
var v = patch[k];
if (v && typeof v === 'object' && !Array.isArray(v)
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
merge(base[k], v);
} else {
base[k] = v;
}
});
return base;
}
function toast(message, bad) {
var node = $('toast');
node.textContent = message;
node.classList.toggle('bad', !!bad);
node.classList.remove('hidden');
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
node.classList.add('hidden');
}, bad ? 6000 : 3000);
}
function fail(message) {
toast(message, true);
}
function duration(ms) {
if (!ms) {
return '';
}
var total = Math.floor(ms / 1000);
var h = Math.floor(total / 3600);
var m = Math.floor((total % 3600) / 60);
var s = total % 60;
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
return h ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s);
}
// --- saving ---------------------------------------------------------------
// Edits are coalesced: holding Enter on a choice fires a change per press and
// there is no reason to write the settings file that often.
function setSetting(path, value) {
var patch = UI.patchFor(path, value);
merge(state.settings, patch);
pendingPatch = pendingPatch ? merge(pendingPatch, patch) : patch;
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(flush, 400);
}
function flush() {
saveTimer = null;
if (!pendingPatch) {
return;
}
var patch = pendingPatch;
pendingPatch = null;
Luna.setConfig(patch, function (reply) {
if (reply.settings) {
state.settings = reply.settings;
}
if (!reply.saved) {
toast('Saved to /tmp only — settings will be lost on reboot', true);
} else if (reply.restartRequired) {
toast('Restart the capture to apply');
}
}, fail);
}
// --- field building -------------------------------------------------------
// `specs` is a list of { path, label, hint, type, options, wide, when,
// rebuild }. `rebuild` marks a field whose value changes which other fields
// are shown, so the panel is redrawn after it changes.
function buildFields(container, specs, redraw) {
var focusedPath = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-path');
UI.clear(container);
specs.forEach(function (spec) {
if (spec.when && !spec.when(state.settings)) {
return;
}
var value = UI.get(state.settings, spec.path);
var control;
function changed(v) {
setSetting(spec.path, v);
if (spec.rebuild && redraw) {
redraw();
}
}
if (spec.type === 'toggle') {
control = UI.toggle(!!value, changed);
} else if (spec.type === 'choice') {
var options = typeof spec.options === 'function' ? spec.options() : spec.options;
control = UI.choice(options, value, changed);
} else {
control = UI.text(value, changed, {
numeric: spec.type === 'number',
placeholder: spec.placeholder,
});
if (spec.wide) {
control.classList.add('wide');
}
}
control.setAttribute('data-path', spec.path);
container.appendChild(UI.row(spec.label, spec.hint, control));
});
if (focusedPath) {
var again = container.querySelector('[data-path="' + focusedPath + '"]');
if (again) {
again.focus();
}
}
}
// --- capture panel --------------------------------------------------------
function backendOptions() {
var out = [{ value: 'auto', label: 'Automatic' }];
state.backends.forEach(function (b) {
out.push({
value: b.id,
label: b.available === false ? b.name + ' (unavailable)' : b.name,
});
});
return out;
}
// Which backends a field applies to. "auto" has to be named explicitly:
// automatic only ever picks PulseAudio or ALSA, so the exec-only fields stay
// hidden until the user asks for that backend by name.
function backendIs(list) {
return function (s) {
return list.indexOf(s.capture && s.capture.backend) >= 0;
};
}
var CAPTURE_FIELDS = [
{
path: 'capture.backend', label: 'Backend', type: 'choice',
options: backendOptions, rebuild: true,
hint: 'Automatic tries PulseAudio, then ALSA.',
},
{
path: 'capture.device', label: 'Device', type: 'text', wide: true,
when: backendIs(['auto', 'pulse', 'alsa']),
placeholder: 'blank = default monitor',
hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. '
+ 'Run diagnostics to see what this TV has.',
},
{
path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true,
when: backendIs(['auto', 'pulse']),
placeholder: 'blank = autodetect',
hint: 'Usually left blank. Example: unix:/var/run/pulse/native',
},
{
path: 'capture.command', label: 'Command', type: 'text', wide: true,
when: backendIs(['exec']),
placeholder: 'parec --format=s16le --rate=48000 --channels=2',
hint: 'Must write raw interleaved S16LE at the rate and channel count below.',
},
{
path: 'capture.rate', label: 'Sample rate', type: 'choice',
options: [
{ value: 44100, label: '44100 Hz' },
{ value: 48000, label: '48000 Hz' },
],
hint: 'The TV mixes at 48 kHz; anything else costs a resample.',
},
{
path: 'capture.channels', label: 'Channels', type: 'choice',
options: [
{ value: 1, label: 'Mono' },
{ value: 2, label: 'Stereo' },
],
},
];
var DSP_FIELDS = [
{
path: 'dsp.attack', label: 'Attack', type: 'number',
hint: 'Seconds to catch a rising level. 0.6 is a slow, calm meter.',
},
{
path: 'dsp.release', label: 'Release', type: 'number',
hint: 'Seconds to fall away after a peak.',
},
];
function renderCapture() {
buildFields($('capture-fields'), CAPTURE_FIELDS, renderCapture);
buildFields($('dsp-fields'), DSP_FIELDS, renderCapture);
}
// --- sinks panel ----------------------------------------------------------
var SINK_FIELDS = {
hyperhdr: [
{
path: 'hyperhdr.host', label: 'Receiver address', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'The machine running HyperHDR and the receiver script.',
},
{ path: 'hyperhdr.port', label: 'UDP port', type: 'number' },
{
path: 'hyperhdr.multicast', label: 'Multicast', type: 'toggle', rebuild: true,
hint: 'Send to a group address instead of one host, so several '
+ 'machines can listen.',
},
{
path: 'hyperhdr.multicastTtl', label: 'Multicast TTL', type: 'number',
when: function (s) { return !!(s.hyperhdr && s.hyperhdr.multicast); },
hint: '1 keeps it on this subnet.',
},
{
path: 'hyperhdr.sapAnnounce', label: 'Announce over SAP', type: 'toggle',
hint: 'Lets PulseAudio find the stream on its own '
+ '(module-rtp-recv, no manual SDP).',
},
],
hyperhdrViz: [
{
path: 'hyperhdrViz.host', label: 'HyperHDR address', type: 'text', wide: true,
placeholder: '192.168.1.50',
},
{
path: 'hyperhdrViz.port', label: 'Flatbuffers port', type: 'number',
hint: 'HyperHDR listens on 19400 by default.',
},
{
path: 'hyperhdrViz.mode', label: 'Style', type: 'choice',
options: [
{ value: 'spectrum', label: 'Spectrum' },
{ value: 'level', label: 'Level bar' },
{ value: 'pulse', label: 'Pulse' },
],
},
{ path: 'hyperhdrViz.width', label: 'Image width', type: 'number' },
{ path: 'hyperhdrViz.height', label: 'Image height', type: 'number' },
{
path: 'hyperhdrViz.fps', label: 'Frames per second', type: 'number',
hint: 'Above 30 buys nothing and costs the TV.',
},
{
path: 'hyperhdrViz.priority', label: 'Priority', type: 'number',
hint: 'Lower wins in HyperHDR. Keep it above your capture source '
+ 'unless you want this to take over.',
},
{ path: 'hyperhdrViz.saturation', label: 'Saturation', type: 'number' },
{
path: 'hyperhdrViz.minBrightness', label: 'Minimum brightness', type: 'number',
hint: '0 lets the lights go fully dark between beats.',
},
],
udp: [
{
path: 'udp.host', label: 'Destination', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'A host, a multicast group, or 255.255.255.255 to broadcast.',
},
{ path: 'udp.port', label: 'Port', type: 'number' },
{ path: 'udp.multicastTtl', label: 'Multicast TTL', type: 'number' },
],
tcp: [
{
path: 'tcp.port', label: 'Listen port', type: 'number',
hint: 'The TV listens; connect to it to pull the audio.',
},
{ path: 'tcp.maxClients', label: 'Maximum clients', type: 'number' },
],
http: [
{ path: 'http.port', label: 'Listen port', type: 'number' },
{ path: 'http.maxClients', label: 'Maximum clients', type: 'number' },
],
};
var SINK_HELP = {
hyperhdr: 'Run host/lgtv-audiocap-receiver.py on the HyperHDR machine. It '
+ 'turns this stream into a sound device HyperHDR can listen to, which is '
+ 'the closest thing to a real audio input HyperHDR has.',
hyperhdrViz: 'No host setup at all: the TV does the analysis and sends '
+ 'finished images over the Flatbuffers port. Use it when you cannot add '
+ 'a sound device on the HyperHDR machine.',
udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
+ 'connection to lose.',
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
+ 'latency when the network stalls.',
http: 'Point VLC at http://<tv>:<port>/audio.wav.',
};
function sinkEnabled(id) {
var list = state.settings.sinks || [];
return list.indexOf(id) >= 0;
}
function setSinkEnabled(id, on) {
var list = (state.settings.sinks || []).slice();
var at = list.indexOf(id);
if (on && at < 0) {
list.push(id);
} else if (!on && at >= 0) {
list.splice(at, 1);
}
setSetting('sinks', list);
}
function renderSinks() {
var host = $('sink-cards');
var focusedId = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-sink');
UI.clear(host);
state.sinkDefs.forEach(function (def) {
var on = sinkEnabled(def.id);
var card = UI.el('div', 'card sink-card' + (on ? '' : ' off'));
var head = UI.el('div', 'sink-head');
head.appendChild(UI.el('h2', null, def.name || def.id));
if (def.id === 'hyperhdr') {
head.appendChild(UI.el('span', 'badge', 'Recommended'));
}
var holder = UI.el('div', 'field-control');
var sw = UI.toggle(on, function (v) {
setSinkEnabled(def.id, v);
renderSinks();
});
sw.setAttribute('data-sink', def.id);
holder.appendChild(sw);
head.appendChild(holder);
card.appendChild(head);
var body = UI.el('div', 'sink-body');
body.appendChild(UI.el('p', 'blurb', SINK_HELP[def.id] || def.description || ''));
buildFields(body, SINK_FIELDS[def.id] || [], renderSinks);
card.appendChild(body);
host.appendChild(card);
});
if (focusedId) {
var again = host.querySelector('[data-sink="' + focusedId + '"]');
if (again) {
again.focus();
}
}
}
// --- system panel ---------------------------------------------------------
var SYSTEM_FIELDS = [
{
path: 'logLevel', label: 'Log level', type: 'choice',
options: [
{ value: 'error', label: 'Errors only' },
{ value: 'warn', label: 'Warnings' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
],
},
];
function renderSystem() {
var host = $('system-fields');
UI.clear(host);
// Two separate things wear one switch: the boot script that launches the
// service, and the setting that tells the service to start capturing.
// Splitting them would only invite the half-on state where the service
// wakes up at boot and then sits there doing nothing.
var boot = UI.toggle(state.settings.autoStart && state.bootLinked, function (v) {
setSetting('autoStart', v);
setBootLink(v);
});
boot.setAttribute('data-path', 'autoStart');
host.appendChild(UI.row(
'Start on boot',
'Installs a Homebrew Channel startup script and starts capturing '
+ 'as soon as the TV comes up.',
boot
));
// The TV's audio devices are root-only. The Homebrew Channel ships the
// tool that grants a service root, but it has to be asked.
var diag = state.diagnostics && state.diagnostics.system;
var rooted = diag && diag.root;
var elevate = UI.button(rooted ? 'Re-apply' : 'Grant root access', grantRoot,
rooted ? null : 'primary');
elevate.setAttribute('data-path', 'elevate');
host.appendChild(UI.row(
'Root access',
diag
? (rooted
? 'The service is running as root.'
: 'The service is running as uid ' + diag.uid + ' and will not be '
+ 'able to open the TV\'s audio devices. Grant it root, then it '
+ 'restarts by itself.')
: 'Checking…',
elevate
));
var fields = UI.el('div');
host.appendChild(fields);
buildFields(fields, SYSTEM_FIELDS, renderSystem);
var info = $('config-path');
UI.clear(info);
info.appendChild(infoItem('Path', state.configPath || '—'));
info.appendChild(infoItem('Storage', state.persistent
? 'Persistent' : 'Temporary (/tmp)'));
info.appendChild(infoItem('Boot script', state.bootLinked
? 'Installed' : 'Not installed'));
}
// Elevation only takes effect on a fresh process, so the service is asked to
// quit and is started again by the next call the page makes.
function grantRoot() {
Luna.exec(ELEVATE + ' ' + SERVICE_ID, function () {
toast('Elevated — restarting the service');
Luna.quit(refreshAfterRestart, refreshAfterRestart);
}, function (err) {
fail('Could not elevate the service: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function refreshAfterRestart() {
setTimeout(function () {
if (statusSub) {
statusSub.cancel();
}
statusSub = Luna.subscribeStatus(renderStatus, fail);
refreshDiagnostics();
}, 1500);
}
function refreshDiagnostics() {
Luna.getDiagnostics(function (reply) {
state.diagnostics = reply;
renderSystem();
}, function () {
// Not fatal: the panel just says "Checking…" until the next attempt.
});
}
function setBootLink(on) {
var command = on
? 'mkdir -p /var/lib/webosbrew/init.d && chmod +x ' + BOOT_SCRIPT
+ ' && ln -sf ' + BOOT_SCRIPT + ' ' + BOOT_LINK
: 'rm -f ' + BOOT_LINK;
Luna.exec(command, function () {
state.bootLinked = on;
renderSystem();
toast(on ? 'Will start with the TV' : 'Boot script removed');
}, function (err) {
state.bootLinked = !on;
renderSystem();
fail('Could not change the boot script: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function checkBootLink() {
Luna.exec('test -e ' + BOOT_LINK + ' && echo yes || echo no', function (reply) {
state.bootLinked = String(reply.stdoutString || '').indexOf('yes') >= 0;
renderSystem();
}, function () {
// No Homebrew Channel service, or it refused. Leave the switch off
// rather than claiming a boot script that is not there.
state.bootLinked = false;
});
}
// --- status ---------------------------------------------------------------
function infoItem(key, value) {
var item = UI.el('div', 'info-item');
item.appendChild(UI.el('div', 'info-key', key));
var v = UI.el('div', 'info-value', value);
v.title = String(value);
item.appendChild(v);
return item;
}
var bandNodes = [];
function buildBands() {
var host = $('bands');
UI.clear(host);
bandNodes = [];
for (var i = 0; i < 16; i++) {
var b = UI.el('div', 'band');
b.style.height = '3px';
host.appendChild(b);
bandNodes.push(b);
}
}
function setBar(id, value) {
var fill = $(id).firstChild;
fill.style.width = (Math.max(0, Math.min(1, value)) * 100).toFixed(1) + '%';
}
function db(value) {
if (value === undefined || value === null || value <= -89) {
return '−∞ dB';
}
return value.toFixed(1) + ' dB';
}
// Detail line under each sink in the status panel. Every sink reports
// different counters, so pick out the ones worth reading at a glance.
function sinkDetail(s) {
var bits = [];
if (s.target) {
bits.push(s.target + ':' + s.port);
} else if (s.port !== undefined) {
bits.push('port ' + s.port);
}
if (s.clients !== undefined) {
bits.push(s.clients + ' client' + (s.clients === 1 ? '' : 's'));
}
if (s.packetsSent !== undefined) {
bits.push(s.packetsSent.toLocaleString() + ' packets');
}
if (s.framesSent !== undefined) {
bits.push(s.framesSent.toLocaleString() + ' frames');
}
if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected');
}
if (s.sendErrors) {
bits.push(s.sendErrors + ' send errors');
}
if (s.droppedBytes) {
bits.push(Math.round(s.droppedBytes / 1024) + ' kB dropped');
}
if (s.lastError) {
bits.push(s.lastError);
}
if (s.error) {
bits.push(s.error);
}
return bits.join(' · ');
}
function renderStatus(st) {
state.status = st;
var pill = $('state-pill');
pill.textContent = {
running: 'Running', starting: 'Starting', error: 'Error',
}[st.state] || 'Stopped';
pill.className = 'pill ' + (st.state || 'stopped');
$('power').textContent = st.running ? 'Stop' : 'Start';
$('uptime').textContent = duration(st.capture && st.capture.uptimeMs);
var levels = st.levels || {};
setBar('meter-peak', levels.peak || 0);
setBar('meter-rms', levels.rms || 0);
$('meter-peak-db').textContent = db(levels.peakDb);
$('meter-rms-db').textContent = db(levels.rmsDb);
$('clip').classList.toggle('hidden', !levels.clipping);
var bands = levels.bands || [];
for (var i = 0; i < bandNodes.length; i++) {
var v = Math.max(0, Math.min(1, bands[i] || 0));
bandNodes[i].style.height = Math.max(3, v * 168).toFixed(0) + 'px';
}
var cap = st.capture || {};
var info = $('capture-info');
UI.clear(info);
info.appendChild(infoItem('Backend', cap.backendName || cap.backend || '—'));
info.appendChild(infoItem('Device', cap.device || 'default'));
info.appendChild(infoItem('Format', cap.rate
? cap.rate + ' Hz · ' + (cap.channels === 1 ? 'mono' : 'stereo') : '—'));
info.appendChild(infoItem('Frames', (cap.frames || 0).toLocaleString()));
if (cap.timeouts) {
info.appendChild(infoItem('Read timeouts', cap.timeouts));
}
var sinks = $('sink-status');
UI.clear(sinks);
if (!st.sinks || !st.sinks.length) {
sinks.appendChild(UI.el('div', 'muted', st.running
? 'No outputs enabled.' : 'Not running.'));
} else {
st.sinks.forEach(function (s) {
var line = UI.el('div', 'sink-line');
line.appendChild(UI.el('span', 'dot ' + (s.ok ? 'ok' : 'bad')));
line.appendChild(UI.el('span', 'sink-name', s.name || s.id));
line.appendChild(UI.el('span', 'sink-detail', sinkDetail(s)));
sinks.appendChild(line);
});
}
$('error-card').classList.toggle('hidden', !st.error);
$('error-text').textContent = st.error || '';
}
// --- tabs -----------------------------------------------------------------
var tabs = [];
function activateTab(panelId) {
tabs.forEach(function (t) {
var on = t.getAttribute('data-panel') === panelId;
t.classList.toggle('active', on);
$(t.getAttribute('data-panel')).classList.toggle('hidden', !on);
});
$('content').scrollTop = 0;
}
function currentTab() {
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].classList.contains('active')) {
return i;
}
}
return 0;
}
// --- actions --------------------------------------------------------------
function togglePower() {
var running = state.status && state.status.running;
// Send any settings the user just touched before restarting, so the run
// picks them up instead of the previous values.
if (saveTimer) {
clearTimeout(saveTimer);
flush();
}
if (running) {
Luna.stop(function () { toast('Stopped'); }, fail);
} else {
Luna.start({}, function (reply) {
if (reply.error) {
fail(reply.error);
} else {
toast('Started');
}
}, fail);
}
}
function showOutput(text) {
var out = $('output');
out.textContent = text;
out.classList.remove('hidden');
out.scrollTop = 0;
}
function runDiagnostics() {
Luna.getDiagnostics(function (reply) {
var copy = JSON.parse(JSON.stringify(reply));
delete copy.returnValue;
showOutput(JSON.stringify(copy, null, 2));
}, fail);
}
function loadLogs(clear) {
Luna.getLogs(clear, function (reply) {
showOutput(reply.logs || '(empty)');
if (clear) {
toast('Log cleared');
}
}, fail);
}
// --- boot -----------------------------------------------------------------
function loadConfig(then) {
Luna.getConfig(function (reply) {
state.settings = reply.settings || {};
state.configPath = reply.path || '';
state.persistent = reply.persistent !== false;
if (then) {
then();
}
}, fail);
}
function init() {
buildBands();
tabs = Array.prototype.slice.call(document.querySelectorAll('.tab'));
tabs.forEach(function (t) {
t.addEventListener('click', function () {
activateTab(t.getAttribute('data-panel'));
});
});
activateTab('panel-status');
$('power').addEventListener('click', togglePower);
$('run-diagnostics').addEventListener('click', runDiagnostics);
$('load-logs').addEventListener('click', function () { loadLogs(false); });
$('clear-logs').addEventListener('click', function () { loadLogs(true); });
$('reset-config').addEventListener('click', function () {
Luna.resetConfig(function (reply) {
state.settings = reply.settings || {};
renderCapture();
renderSinks();
renderSystem();
toast('Settings reset');
}, fail);
});
// Back steps to the Status tab first, and only leaves the app from there.
Nav.onBack(function () {
if (currentTab() !== 0) {
activateTab('panel-status');
Nav.focus(tabs[0]);
return true;
}
return false;
});
loadConfig(function () {
Luna.listBackends(function (reply) {
state.backends = reply.backends || [];
renderCapture();
}, fail);
Luna.listSinks(function (reply) {
state.sinkDefs = reply.sinks || [];
renderSinks();
}, fail);
renderCapture();
renderSystem();
checkBootLink();
refreshDiagnostics();
});
statusSub = Luna.subscribeStatus(renderStatus, function (err) {
fail('Lost contact with the service: ' + err);
$('state-pill').textContent = 'No service';
$('state-pill').className = 'pill error';
});
Nav.focus($('power'));
// webOS suspends the page rather than unloading it, so drop the
// subscription on the way out and pick it back up on return.
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
if (statusSub) {
statusSub.cancel();
statusSub = null;
}
} else if (!statusSub) {
statusSub = Luna.subscribeStatus(renderStatus, fail);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
global.App = { state: state };
})(window);
+132
View File
@@ -0,0 +1,132 @@
// Luna bus access.
//
// Talks to PalmServiceBridge directly rather than pulling in webOSTV.js: it is
// the same object webOSTV.js wraps, it is injected into every webOS app, and
// it means there is no third-party file to keep in sync.
//
// Opened in a desktop browser the bridge is absent, so everything falls back
// to a small mock. That is what makes the UI developable without a TV.
(function (global) {
'use strict';
var SERVICE = 'luna://org.webosbrew.audiocap.service/';
var HBCHANNEL = 'luna://org.webosbrew.hbchannel.service/';
var haveBridge = typeof global.PalmServiceBridge !== 'undefined';
// A call in flight. `cancel()` tears down a subscription.
function Request(bridge) {
this.bridge = bridge;
this.cancelled = false;
}
Request.prototype.cancel = function () {
this.cancelled = true;
if (this.bridge && this.bridge.cancel) {
this.bridge.cancel();
}
};
// Low-level call. `onReply` fires once per reply, so subscriptions keep
// calling it until cancelled.
function call(uri, params, onReply, onError) {
if (!haveBridge) {
return global.LunaMock.call(uri, params, onReply, onError);
}
var bridge = new global.PalmServiceBridge();
var request = new Request(bridge);
bridge.onservicecallback = function (raw) {
if (request.cancelled) {
return;
}
var reply;
try {
reply = JSON.parse(raw);
} catch (e) {
if (onError) {
onError('Malformed reply from ' + uri);
}
return;
}
// Luna reports both its own failures and ours through returnValue.
if (reply.returnValue === false) {
if (onError) {
onError(reply.errorText || reply.errorMessage || 'Call to ' + uri + ' failed');
}
return;
}
if (onReply) {
onReply(reply);
}
};
try {
bridge.call(uri, JSON.stringify(params || {}));
} catch (e) {
if (onError) {
onError(String(e));
}
}
return request;
}
var Luna = {
available: haveBridge,
// --- our service -------------------------------------------------------
start: function (patch, ok, fail) {
return call(SERVICE + 'start', patch || {}, ok, fail);
},
stop: function (ok, fail) {
return call(SERVICE + 'stop', {}, ok, fail);
},
subscribeStatus: function (ok, fail) {
return call(SERVICE + 'getStatus', { subscribe: true }, ok, fail);
},
getConfig: function (ok, fail) {
return call(SERVICE + 'getConfig', {}, ok, fail);
},
setConfig: function (patch, ok, fail) {
return call(SERVICE + 'setConfig', { settings: patch }, ok, fail);
},
resetConfig: function (ok, fail) {
return call(SERVICE + 'resetConfig', {}, ok, fail);
},
listBackends: function (ok, fail) {
return call(SERVICE + 'listBackends', {}, ok, fail);
},
listSinks: function (ok, fail) {
return call(SERVICE + 'listSinks', {}, ok, fail);
},
getDiagnostics: function (ok, fail) {
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
},
// Ends the service process. Any later call starts a fresh one, which is
// how the service picks up new bus permissions after being elevated.
quit: function (ok, fail) {
return call(SERVICE + 'quit', {}, ok, fail);
},
// `clear` empties the ring buffer after reading it, so the reply is the
// last thing anyone sees of those lines.
getLogs: function (clear, ok, fail) {
return call(SERVICE + 'getLogs', { clear: !!clear }, ok, fail);
},
// --- Homebrew Channel --------------------------------------------------
// Used for the boot symlink. Needs the Homebrew Channel installed, which
// it will be on any TV that can run this app.
exec: function (command, ok, fail) {
return call(HBCHANNEL + 'exec', { command: command }, ok, fail);
},
// --- system ------------------------------------------------------------
getNetworkStatus: function (ok, fail) {
return call('luna://com.palm.connectionmanager/getStatus', {}, ok, fail);
},
};
global.Luna = Luna;
})(window);
+203
View File
@@ -0,0 +1,203 @@
// Stand-in for the Luna bus so the UI can be opened in a desktop browser.
// Only loaded when PalmServiceBridge is missing, which never happens on a TV.
(function (global) {
'use strict';
var settings = {
autoStart: false,
logLevel: 'info',
capture: { backend: 'auto', device: '', server: '', command: '', rate: 48000, channels: 2 },
dsp: { attack: 0.6, release: 0.12 },
sinks: ['hyperhdr'],
hyperhdr: { host: '192.168.1.50', port: 5004, multicast: false, multicastTtl: 4, sapAnnounce: true },
hyperhdrViz: {
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
},
udp: { host: '', port: 4010, multicastTtl: 4 },
tcp: { port: 4011, maxClients: 4 },
http: { port: 4012, maxClients: 4 },
};
var running = false;
var subscribers = [];
var startedAt = 0;
function merge(base, patch) {
Object.keys(patch).forEach(function (k) {
if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k])
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
merge(base[k], patch[k]);
} else {
base[k] = patch[k];
}
});
}
function status(subscribed) {
var t = Date.now() / 1000;
var bands = [];
for (var i = 0; i < 16; i++) {
var v = running ? Math.abs(Math.sin(t * (1 + i * 0.25) + i)) * (1 - i / 24) : 0;
bands.push(Math.max(0, Math.min(1, v)));
}
var peak = running ? 0.4 + 0.4 * Math.abs(Math.sin(t * 2)) : 0;
return {
returnValue: true,
subscribed: !!subscribed,
state: running ? 'running' : 'stopped',
running: running,
error: null,
capture: {
backend: running ? 'pulse' : null,
backendName: running ? 'PulseAudio (mock)' : null,
device: '@DEFAULT_MONITOR@',
rate: 48000,
channels: 2,
frames: running ? Math.round((Date.now() - startedAt) * 48) : 0,
blocks: running ? Math.round((Date.now() - startedAt) / 10.7) : 0,
timeouts: 0,
uptimeMs: running ? Date.now() - startedAt : 0,
},
levels: {
peak: peak,
rms: peak * 0.6,
peakDb: peak > 0 ? 20 * Math.log(peak) / Math.LN10 : -90,
rmsDb: peak > 0 ? 20 * Math.log(peak * 0.6) / Math.LN10 : -90,
clipping: peak > 0.98,
bands: bands,
},
sinks: running ? settings.sinks.map(function (id) {
return { id: id, ok: true, name: id, error: null, target: settings.hyperhdr.host, port: settings.hyperhdr.port, packetsSent: 1234 };
}) : [],
configPath: '/var/lib/webosbrew/audiocap/config.json',
configPersistent: true,
};
}
setInterval(function () {
subscribers.forEach(function (s) {
if (!s.cancelled) {
s.onReply(status(true));
}
});
}, 100);
function respond(onReply, payload) {
setTimeout(function () { onReply(payload); }, 30);
return { cancel: function () {} };
}
var LunaMock = {
call: function (uri, params, onReply, onError) {
var method = uri.split('/').pop();
switch (method) {
case 'start':
if (params && Object.keys(params).length) { merge(settings, params); }
running = true;
startedAt = Date.now();
return respond(onReply, status(false));
case 'stop':
running = false;
return respond(onReply, status(false));
case 'getStatus': {
var sub = { cancelled: false, onReply: onReply };
if (params && params.subscribe) {
subscribers.push(sub);
}
setTimeout(function () { onReply(status(!!(params && params.subscribe))); }, 30);
return { cancel: function () { sub.cancelled = true; } };
}
case 'getConfig':
return respond(onReply, {
returnValue: true,
path: '/var/lib/webosbrew/audiocap/config.json',
persistent: true,
settings: JSON.parse(JSON.stringify(settings)),
});
case 'setConfig':
merge(settings, params.settings || params);
return respond(onReply, {
returnValue: true, saved: true, restartRequired: running,
settings: JSON.parse(JSON.stringify(settings)),
});
case 'listBackends':
return respond(onReply, {
returnValue: true,
backends: [
{ id: 'pulse', name: 'PulseAudio', description: 'Records a PulseAudio monitor source.', available: true },
{ id: 'alsa', name: 'ALSA', description: 'Records from an ALSA capture PCM.', available: true },
{ id: 'exec', name: 'External command', description: 'Reads raw PCM from a command you supply.', available: true },
{ id: 'tone', name: 'Test tone', description: 'Synthesised sweep, for testing the transport.', available: true },
],
});
case 'listSinks':
return respond(onReply, {
returnValue: true,
sinks: [
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
{ id: 'hyperhdrViz', name: 'HyperHDR visualiser', description: 'Renders on the TV, sends images. No host setup.' },
{ id: 'udp', name: 'Raw PCM over UDP', description: 'Fire-and-forget S16LE datagrams.' },
{ id: 'tcp', name: 'Raw PCM over TCP', description: 'The TV listens; connect to pull audio.' },
{ id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
],
});
// Same shape as capture_write_diagnostics(): backends at the top level,
// everything about the machine under "system".
case 'getDiagnostics':
return respond(onReply, {
returnValue: true,
backends: [
{ id: 'pulse', name: 'PulseAudio monitor', available: true },
{ id: 'alsa', name: 'ALSA PCM', available: true },
{ id: 'exec', name: 'External command', available: true },
{ id: 'tone', name: 'Test tone', available: true },
],
system: {
root: true,
uid: 0,
libraries: {
'libpulse.so.0': '/usr/lib/libpulse.so.0',
'libpulse-simple.so.0': null,
'libasound.so.2': '/usr/lib/libasound.so.2',
},
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
pulseSockets: ['/var/run/pulse/native'],
pactlSources: 'mock output',
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
},
});
case 'getLogs':
return respond(onReply, {
returnValue: true,
logs: '[info] running in the browser mock\n[info] no TV attached\n',
});
case 'quit':
running = false;
return respond(onReply, { returnValue: true });
case 'exec':
return respond(onReply, { returnValue: true, stdoutString: '' });
default:
if (onError) { onError('mock: unknown method ' + method); }
return { cancel: function () {} };
}
},
};
global.LunaMock = LunaMock;
})(window);
+171
View File
@@ -0,0 +1,171 @@
// Remote-control navigation.
//
// webOS TVs have no tab key and no pointer worth designing for, so focus moves
// geometrically: pressing Right picks the nearest focusable element whose
// centre lies to the right, biased towards ones on the same row. Anything with
// the `focusable` class joins in, which keeps the markup free of tab indices.
(function (global) {
'use strict';
var KEY = {
LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40,
ENTER: 13, BACK: 461, ESCAPE: 27,
RED: 403, GREEN: 404, YELLOW: 405, BLUE: 406,
};
function visible(el) {
if (el.disabled || el.classList.contains('hidden')) {
return false;
}
var rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function candidates() {
var all = document.querySelectorAll('.focusable');
var out = [];
for (var i = 0; i < all.length; i++) {
if (visible(all[i])) {
out.push(all[i]);
}
}
return out;
}
function centre(el) {
var r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: r };
}
// Minimum travel before a neighbour counts as being in that direction, so
// elements that merely overlap slightly do not steal focus.
var MIN_TRAVEL = 4;
// How much drifting off-axis costs. High enough that a row of buttons is
// traversed in order rather than diagonally.
var ACROSS_PENALTY = 3;
function move(direction) {
var current = document.activeElement;
var list = candidates();
if (!list.length) {
return;
}
if (!current || !current.classList || !current.classList.contains('focusable')) {
focus(list[0]);
return;
}
var from = centre(current);
var best = null;
var bestScore = Infinity;
for (var i = 0; i < list.length; i++) {
if (list[i] === current) {
continue;
}
var to = centre(list[i]);
var dx = to.x - from.x;
var dy = to.y - from.y;
var along;
var across;
if (direction === 'right') {
along = dx;
across = dy;
} else if (direction === 'left') {
along = -dx;
across = dy;
} else if (direction === 'down') {
along = dy;
across = dx;
} else {
along = -dy;
across = dx;
}
if (along < MIN_TRAVEL) {
continue;
}
var s = along + Math.abs(across) * ACROSS_PENALTY;
if (s < bestScore) {
bestScore = s;
best = list[i];
}
}
if (best) {
focus(best);
}
}
function focus(el) {
if (!el) {
return;
}
el.focus();
// Keep the focused control clear of the sticky header.
if (el.scrollIntoView) {
var rect = el.getBoundingClientRect();
if (rect.top < 120 || rect.bottom > global.innerHeight - 40) {
el.scrollIntoView({ block: 'center' });
}
}
}
function focusFirstIn(container) {
if (!container) {
return;
}
var list = container.querySelectorAll('.focusable');
for (var i = 0; i < list.length; i++) {
if (visible(list[i])) {
focus(list[i]);
return;
}
}
}
var backHandler = null;
document.addEventListener('keydown', function (e) {
switch (e.keyCode) {
case KEY.LEFT:
move('left');
break;
case KEY.RIGHT:
move('right');
break;
case KEY.UP:
move('up');
break;
case KEY.DOWN:
move('down');
break;
case KEY.BACK:
case KEY.ESCAPE:
if (backHandler && backHandler()) {
break;
}
// Nothing wanted the Back press: leave the app the way the platform
// expects rather than trapping the user inside it.
if (global.webOS && global.webOS.platformBack) {
global.webOS.platformBack();
} else {
global.close();
}
break;
default:
return;
}
e.preventDefault();
});
global.Nav = {
KEY: KEY,
focus: focus,
focusFirstIn: focusFirstIn,
onBack: function (fn) { backHandler = fn; },
};
})(window);
+195
View File
@@ -0,0 +1,195 @@
// Form controls built for a remote control.
//
// Everything is a button. Dropdowns and checkboxes are miserable to operate
// with a D-pad, so a choice cycles through its values on Enter and a toggle
// flips. Text fields are the one exception: focusing one and pressing Enter
// brings up the TV's on-screen keyboard, which is the only way to type.
(function (global) {
'use strict';
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = String(text);
}
return node;
}
// Reads "hyperhdr.host" out of a settings object.
function get(obj, path) {
var parts = path.split('.');
var cur = obj;
for (var i = 0; i < parts.length; i++) {
if (cur === null || cur === undefined) {
return undefined;
}
cur = cur[parts[i]];
}
return cur;
}
// Builds { hyperhdr: { host: value } } so setConfig only carries the change.
function patchFor(path, value) {
var parts = path.split('.');
var root = {};
var cur = root;
for (var i = 0; i < parts.length - 1; i++) {
cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
return root;
}
function row(label, hint, control) {
var wrap = el('div', 'field');
var text = el('div', 'field-text');
text.appendChild(el('div', 'field-label', label));
if (hint) {
text.appendChild(el('div', 'field-hint', hint));
}
wrap.appendChild(text);
var holder = el('div', 'field-control');
holder.appendChild(control);
wrap.appendChild(holder);
return wrap;
}
function button(label, onClick, extraClass) {
var b = el('button', 'focusable btn' + (extraClass ? ' ' + extraClass : ''), label);
b.type = 'button';
b.addEventListener('click', onClick);
return b;
}
function toggle(value, onChange) {
var b = el('button', 'focusable btn toggle');
b.type = 'button';
function paint(v) {
b.textContent = v ? 'On' : 'Off';
b.classList.toggle('on', !!v);
b.setAttribute('aria-pressed', v ? 'true' : 'false');
}
paint(value);
b.addEventListener('click', function () {
value = !value;
paint(value);
onChange(value);
});
b.setValue = paint;
return b;
}
// `options` is [{ value, label }].
function choice(options, value, onChange) {
var b = el('button', 'focusable btn choice');
b.type = 'button';
function indexOf(v) {
for (var i = 0; i < options.length; i++) {
if (options[i].value === v) {
return i;
}
}
return 0;
}
var index = indexOf(value);
function paint() {
b.textContent = options.length ? options[index].label : '—';
b.title = options.length ? String(options[index].value) : '';
}
paint();
b.addEventListener('click', function () {
if (!options.length) {
return;
}
index = (index + 1) % options.length;
paint();
onChange(options[index].value);
});
b.setValue = function (v) {
index = indexOf(v);
paint();
};
b.setOptions = function (list, v) {
options = list;
index = indexOf(v);
paint();
};
return b;
}
function text(value, onChange, opts) {
opts = opts || {};
var input = el('input', 'focusable input');
input.type = 'text';
input.value = value === undefined || value === null ? '' : String(value);
if (opts.placeholder) {
input.placeholder = opts.placeholder;
}
if (opts.numeric) {
input.inputMode = 'numeric';
}
function commit() {
var raw = input.value.trim();
onChange(opts.numeric ? (raw === '' ? 0 : parseFloat(raw)) : raw);
}
input.addEventListener('change', commit);
input.addEventListener('blur', commit);
input.addEventListener('keydown', function (e) {
// Let the arrow keys move the caret inside a focused field, but hand Up
// and Down back to the navigator so the user can leave it.
if (e.keyCode === 37 || e.keyCode === 39) {
e.stopPropagation();
}
if (e.keyCode === 13) {
commit();
}
});
input.setValue = function (v) {
input.value = v === undefined || v === null ? '' : String(v);
};
return input;
}
// A horizontal bar, 0..1.
function bar(className) {
var outer = el('div', 'bar ' + (className || ''));
var fill = el('div', 'bar-fill');
outer.appendChild(fill);
outer.setValue = function (v) {
var pct = Math.max(0, Math.min(1, v)) * 100;
fill.style.width = pct.toFixed(1) + '%';
};
return outer;
}
function clear(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
global.UI = {
el: el,
get: get,
patchFor: patchFor,
row: row,
button: button,
toggle: toggle,
choice: choice,
text: text,
bar: bar,
clear: clear,
};
})(window);