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,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);
|
||||
Reference in New Issue
Block a user